워드프레스 하나 운영하자고 맥미니 안에 Ubuntu 가상머신까지 만드는 건 솔직히 가장 쉬운 길은 아니다.
글만 빨리 쓰고 싶다면 관리형 호스팅을 신청하는 편이 훨씬 간단하다.
이미 쓰던 맥미니가 있어 새 장비 대신 그 안에 Ubuntu VM을 하나 두었다. 조건은 두 가지였다.
기존 macOS 작업과 WordPress 서버를 섞지 않을 것, 공유기 포트는 열지 않을 것.
그렇게 올린 ttakdap.com은 WordPress와 MariaDB를 VM 안에서 돌리고, 외부 연결은 Cloudflare Tunnel이 맡고 있다.
WordPress 설치보다 시간을 더 쓴 건 공개 범위를 정하고, 관리자 입구를 따로 잠그고, 고장 났을 때 되살릴 길을 만드는 일이었다.
현재 판정 맥미니로 24시간 운영을 시작할 준비는 갖췄다. 하지만 장기간의 안정성과 정전 후 완전한 무인 복구까지 입증한 것은 아니다.
그림 1. 방문자는 Cloudflare Tunnel을 거쳐 WordPress에 접속하고, MariaDB는 Docker 내부 네트워크에만 둔 실제 구성.
백업은 VM 안의 복구 세트와 암호화된 R2 사본으로 나눴다.
직접 따라 하는 설치 안내
이 글은 구성 이유와 운영하면서 확인한 장단점을 정리한 글이다. 처음부터 같은 서버를 만들고 싶다면 아래 설치 시리즈를 순서대로 보면 된다. 각 편은 앞 단계의 성공 여부를 확인한 뒤 다음 단계로 넘어가도록 나눴다.
한 글로 모두 합치지 않은 이유는 설치 위치와 오류 범위를 분명히 하기 위해서다. 1편에서는 Ubuntu와 SSH까지만, 2편에서는 외부에 공개하지 않은 WordPress 내부 구동까지 확인한다.
이번 구성
호스트는 M4 Pro 12코어와 메모리 64GB를 갖춘 맥미니다. 이 사양이 WordPress 운영에 꼭 필요하다는 뜻은 아니다.
이번 글은 성능 한계를 재는 벤치마크가 아니라, 가지고 있던 맥미니에 서버 역할을 분리해 올린 기록이다.
구분
실제 구성
가상머신
Parallels의 Ubuntu Server 24.04 LTS ARM64
VM 자원
4 vCPU, 메모리 8GB, 64GB 확장형 디스크, NAT
웹
Docker Compose의 WordPress · PHP · Apache
데이터베이스
별도 MariaDB 컨테이너
외부 연결
Cloudflare DNS · TLS · Tunnel
관리자 보호
/wp-login.php, /wp-admin/*에 Cloudflare Access
예약 작업
WordPress Cron 5분마다, 로컬 백업 6시간마다
외부 백업
하루 최대 한 번, 암호화 후 Cloudflare R2로 전송
컨테이너 이미지는 계속 바뀌는 latest 대신 구축할 때 확인한 버전으로 고정했다.
업데이트는 새 이미지를 무조건 받게 두지 않고, 백업을 먼저 만든 다음 재기동과 상태 검사를 거치도록 순서를 정했다.
macOS에 바로 설치하지 않고 VM으로 나눈 이유
처음에는 macOS에서 Docker를 바로 실행하는 방법도 생각했다.
다만 이 맥에는 이미 다른 컨테이너가 돌아가고 있었다. 사이트 서버를 수정하다 기존 작업까지 건드리는 상황은 피하고 싶었다.
그래서 WordPress 운영 환경을 Parallels의 Ubuntu VM 하나에 몰아넣었다.
VM으로 나누니 macOS 쪽 작업과 WordPress 서버가 얽히지 않았다.
Linux 서버용 문서와 명령도 거의 그대로 쓸 수 있었다.
이상이 생겼을 때는 Mac → VM → 컨테이너 순으로 확인 범위를 좁힐 수 있다.
대신 부팅 경로가 길어졌다. 맥미니, macOS, Parallels, Ubuntu, Docker가 모두 올라와야 사이트가 열린다.
컨테이너에 자동 재시작 옵션을 넣었다고 해서 정전 후 모든 단계가 저절로 해결되는 것은 아니다.
이 점은 나중에 다시 설명하겠지만, 맥미니를 24시간 서버로 볼 때 가장 큰 약점이기도 하다.
한 번에 공개하지 않고 안쪽부터 확인했다
처음부터 WordPress와 데이터베이스, Tunnel을 한꺼번에 인터넷에 연결하지는 않았다. 먼저 Ubuntu VM 안에서 MariaDB와 WordPress만 올리고, 데이터베이스가 준비됐는지와 WordPress 컨테이너의 80번 포트가 내부 연결을 받는지를 확인했다. 이 단계에서는 Tunnel 토큰을 넣지 않아 사이트가 아직 외부 주소로 연결되지 않았다.
내부 구동을 확인한 뒤에야 cloudflared를 추가했다. WordPress 초기 설정이 끝나기 전에는 사이트 전체를 소유자만 통과할 수 있도록 임시로 잠갔고, 설정을 마친 다음 공개 글과 홈페이지의 잠금을 풀었다. 지금은 /wp-login.php와 /wp-admin/*만 Cloudflare Access로 보호하고, 공개 페이지는 인증 없이 열린다.
순서를 나누니 문제가 생겼을 때 확인할 범위도 줄었다. VM 안에서도 열리지 않으면 WordPress나 MariaDB 쪽을 보고, 내부에서는 열리는데 주소로 접속되지 않으면 Tunnel·DNS·Access 설정을 확인하면 된다.
실제로 해보니 홈서버를 공개하는 일은 ‘컨테이너를 실행했다’에서 끝나지 않았다. 각 단계의 성공 조건을 확인하면서 접속 범위를 조금씩 넓히는 과정에 가까웠다.
MariaDB·WordPress 내부 기동 → 내부 상태 검사 → Tunnel 연결 → 초기 설정 동안 임시 전체 보호 → 공개 페이지 개방·관리자 경로만 보호
그림 2. 실제 배포 순서를 기준으로, 외부 접속 범위를 한 단계씩 넓힌 흐름을 재구성했다.
공유기 80·443 포트는 열지 않았다
홈서버를 인터넷에 공개할 때 가장 먼저 떠올린 것은 포트포워딩이었다. 이번 구성에서는 공유기에 웹용 포트포워딩을 만들지 않았고, WordPress용 Docker Compose에도 호스트 포트를 공개하는 ports 항목을 넣지 않았다.
대신 VM 안의 cloudflared 컨테이너가 Cloudflare 쪽으로 먼저 연결한다. 방문자는 Cloudflare를 거쳐 이 터널로 WordPress에 들어온다.
집 밖에서 VM으로 직접 들어오는 길을 새로 만드는 대신, 서버가 외부로 내보내는 연결을 이용한 셈이다.
MariaDB는 더 안쪽에 뒀다. WordPress와 DB만 연결되는 Docker 내부 전용 네트워크를 만들고, DB 포트는 외부는 물론 호스트에도 공개하지 않았다.
다만 “Tunnel을 썼으니 안전하다”고 말할 수는 없다. WordPress와 플러그인을 업데이트해야 하고, 계정 보안과 백업도 따로 챙겨야 한다.
포트를 공개하지 않은 것은 공격면을 줄인 것이지 보안 작업을 끝낸 것이 아니다.
공개 사이트와 관리자 화면을 따로 취급했다
관리자 로그인 앞에는 Cloudflare Access를 한 겹 더 뒀다. 보호 대상은 WordPress 로그인 화면과 관리자 경로다.
/wp-login.php
/wp-admin/*
사이트 전체를 Access로 잠그지는 않았다. 그렇게 하면 일반 방문자뿐 아니라 검색엔진과 광고 크롤러도 인증 화면으로 보내질 수 있기 때문이다.
공개 글과 홈페이지는 로그인 없이 열고, 관리자 화면만 별도로 보호했다.
admin-ajax.php는 이름과 달리 공개 테마나 플러그인에서도 사용할 수 있다. 다만 현재 확인에서는 이 경로도 관리자 규칙과 함께 Access 보호를 받고 있다. 공개 화면의 실제 기능이 이 경로를 요구하는 것이 확인되면 그때 정확한 경로만 조건부로 예외 처리할 계획이다. 필요성을 확인하지 않고 먼저 열지는 않았다.
DB 비밀번호와 Tunnel 토큰도 Compose 파일이나 일반 환경변수에 직접 적지 않았다.
권한을 제한한 파일 기반 secret으로 전달했고, Tunnel 컨테이너에는 읽기 전용 파일시스템과 권한 축소 설정을 적용했다.
완벽한 방어라고 부를 수는 없지만, 실수로 비밀값이 로그나 설정 화면에 노출될 가능성을 줄이는 데는 의미가 있다.
running 세 글자만 믿지 않도록 했다
Docker에서 컨테이너가 running으로 보이더라도 WordPress가 실제로 DB에 로그인하지 못하거나 Apache의 80번 포트가 연결을 받지 못할 수 있다.
그래서 상태 확인을 몇 단계로 나눴다.
MariaDB가 연결을 받을 준비가 됐는지, WordPress가 secret 파일을 읽는지, 그 비밀번호로 DB 인증이 되는지, WordPress 컨테이너 안에서 Apache의 80번 포트에 TCP 연결이 되는지 차례로 검사한다. 이 기본 검사는 HTTP 본문까지 확인하는 검사가 아니다. Tunnel도 잠깐 실행됐다가 종료되는 상태를 놓치지 않도록 연속된 확인을 넣었다.
업데이트 순서도 같은 이유로 정했다.
새 복구 세트 생성 → 이미지 업데이트 → 컨테이너 재기동 → 내부 상태 검사 → 외부 접속 확인
초안 작성일에 Ubuntu의 예약 작업 상태를 다시 보니 WordPress Cron 타이머와 6시간 주기 백업 타이머가 모두 활성 상태였고,
다음 실행 시각도 정상적으로 잡혀 있었다.
그림 3. 2026년 9월 1일 읽기 전용으로 다시 확인한 결과를 사용자명·호스트명·내부 주소 없이 재구성했다. 특정 시점의 상태이며 장기 가동률을 뜻하지는 않는다.
백업은 ‘만들었다’보다 ‘다시 읽혔다’가 중요했다
VM 안에만 백업을 두면 가상 디스크나 맥미니 저장장치에 문제가 생길 때 원본과 백업을 함께 잃을 수 있다. 그래서 VM 안의 복구 세트와 R2 사본을 나눴다.
현재는 6시간마다 같은 시각표를 가진 복구 세트 하나를 만든다. 여기에는 MariaDB 논리 덤프, WordPress 전체 파일, 비밀값을 제외한 배포 설정, 실제 Compose 구성, 컨테이너 이미지 목록, SHA-256 검증값이 들어간다. 작성 중인 백업은 임시 위치에 두고, 모든 파일의 검증이 끝나야 완성된 복구 세트로 취급한다.
로컬 복구 세트의 기본 보존 기간은 14일이다. 6시간 간격이면 설정상 약 56세트가 남는다.
사진과 영상이 늘면 전체 파일 압축본도 커지기 때문에, 이 숫자만 믿지 않고 VM 디스크 사용량을 함께 봐야 한다.
하루에 최대 한 세트는 rclone으로 암호화해 Cloudflare R2로 보낸다. 구성할 때는 업로드 성공 메시지만 보고 끝내지 않았다.
같은 복구 세트를 다시 내려받아 SHA-256 검사가 통과하는 것까지 확인했다.
이번에 확인한 범위는 여기까지다. R2에서 다시 받은 파일의 SHA-256은 일치했지만, 빈 Ubuntu VM에서 사이트 전체를 복원하는 시험은 아직 하지 않았다.
그래서 24시간 서버라고 불러도 될까
사이트는 외부에서 열리고, WordPress·DB·Tunnel 컨테이너에는 재시작 정책이 들어가 있다.
WordPress Cron은 5분마다, 백업은 6시간마다 Ubuntu의 systemd 타이머가 실행한다.
서버가 잠깐 꺼져 예약 시간을 놓쳤을 때 다시 처리하도록 Persistent 설정도 켰다.
그런데 이 자동화는 Ubuntu VM이 이미 부팅된 다음 이야기다.
정전이 나면 맥미니가 다시 켜져야 하고, macOS 로그인과 Parallels 시작을 거쳐 Ubuntu VM이 올라와야 한다.
현재 구성에서는 사용자 로그인 전에도 Parallels와 Ubuntu VM이 시작되는지 아직 검증하지 않았다.
그래서 지금 “정전 뒤에도 사람 손 없이 자동 복구된다”고 쓰면 과장이다. 실제로 남은 시험은 다음과 같다.
전원을 끊었다가 넣었을 때 맥미니가 다시 켜지는가.
사용자 로그인 없이 Parallels와 Ubuntu VM이 시작되는가.
Docker와 세 컨테이너가 올바른 순서로 정상화되는가.
놓친 Cron과 백업 작업이 처리되는가.
외부에서 사이트가 다시 열릴 때까지 몇 분이 걸리는가.
수개월간의 가동률, 실제 소비전력, 온도와 팬 소음, 동시 접속 성능도 아직 측정하지 않았다.
이런 수치를 다른 사람의 맥미니 사용기에서 가져와 채우기보다는, 스마트 플러그와 모니터링 기록을 쌓은 뒤 후속 글에 추가할 생각이다.
지금 내린 결론
이미 쓰던 맥미니가 있고 서버 운영 자체를 배워 보고 싶다면 해볼 만했다. 반대로 글 쓰는 데만 집중하고 싶거나, 장애 뒤 정해진 시간 안에 복구돼야 한다면 관리형 호스팅이 편하다. 이 구성에서는 문제가 생길 때 macOS부터 Cloudflare까지 직접 확인해야 한다.
지금까지 끝낸 것은 사이트 공개, 관리자 경로 보호, 로컬·R2 백업 파일 검증까지다. 정전 뒤 무인 기동과 빈 VM 전체 복구는 아직 남았다.
두 시험을 마친 뒤에야 이 서버를 24시간 운영용이라고 마음 놓고 부를 수 있을 것 같다.
그림 4. 2026년 9월 1일 관리자 표시를 제외하고 다시 캡처한 딱답 공개 홈페이지. 현재 맥미니의 Ubuntu VM에서 서비스 중이다.
3편까지 끝내자 사이트는 밖에서 열렸다. 하지만 사람이 접속하지 않는 시간에도 예약 작업이 제때 실행되는지, VM이 사라졌을 때 백업을 다시 내려받을 수 있는지는 별개의 문제였다.
이번에는 사이트를 더 공개하는 작업이 아니다. 사람이 보고 있지 않을 때도 WordPress의 예약 작업을 확인하게 만들고, 고장 뒤에 사용할 복구 재료를 남기는 작업이다. R2에 업로드 완료가 보였다는 이유만으로 끝내지 않고, 같은 세트를 다시 내려받아 체크섬과 압축 파일을 읽을 수 있는 데까지 확인했다.
그림 1. WP-Cron은 5분마다, 로컬 복구 세트는 6시간마다 실행한다. R2에는 하루 최대 한 세트를 클라이언트 측에서 암호화해 보낸다.
이번 글을 끝내면 다음 상태가 된다.
방문자 요청과 무관하게 WordPress 예약 작업을 5분마다 확인한다.
6시간마다 MariaDB와 WordPress 파일을 하나의 UTC 시각 이름을 가진 폴더에 묶는다.
작성 중인 백업과 완성된 백업을 구분하고 SHA-256을 생성 직후 검사한다.
.env, DB 비밀번호, Tunnel 토큰, R2 설정은 백업 압축에서 제외한다.
하루 최대 한 세트를 rclone crypt로 암호화해 비공개 R2 버킷에 보낸다.
R2 사본을 다시 내려받아 SHA-256, gzip, tar 읽기 검사를 한다.
최초 R2 왕복 검증 뒤 로컬 완성 세트의 14일 보존 정책을 활성화한다.
진행 순서는 WP-Cron 수동 확인 → 로컬 복구 세트 → R2 왕복 검증 → systemd 자동 운영이다. 앞 단계의 성공 문구를 확인하지 못했다면 다음 단계로 넘어가지 않는다.
여기서 확인한 것은 복구 파일의 무결성과 R2 왕복·복호화 경로다. 빈 Ubuntu VM에 WordPress 파일을 풀고 새 MariaDB에 SQL을 가져와 사이트를 실제로 여는 전체 복원 시험은 아직 하지 않았다. 따라서 이 글에서는 전체 복원 성공이라고 부르지 않는다.
이 글은 2026년 9월 1일의 Ubuntu Server 24.04 ARM64, WordPress 7.1.0, MariaDB 11.4.13, rclone 1.75.0과 실제 홈서버 구성을 기준으로 한다. 버전과 Cloudflare 메뉴는 나중에 달라질 수 있다.
남은 공간이 WordPress 파일과 MariaDB 디렉터리의 현재 크기를 합친 값보다 충분히 크다.
백업 파일은 WordPress 전체 파일을 매번 압축하므로 미디어가 늘수록 빠르게 커진다. 6시간 × 14일 = 약 56세트라는 개수만 보고 공간이 충분하다고 판단하지 않는다.
Parallels에서는 Ubuntu를 정상 종료한 뒤 3편 공개 완료 같은 이름으로 스냅샷을 하나 만들어 두면 좋다. 만든 뒤 Ubuntu를 다시 켜고 SSH로 재접속한다. 이 스냅샷은 이번 설정 변경을 되돌리는 용도이지, 같은 Mac 저장장치에 있는 장기 백업을 대신하지는 않는다.
1. WP-Cron이 하는 일부터 구분하기
WordPress의 기본 WP-Cron은 계속 실행되는 데몬이 아니다. 일반 페이지 요청이 들어올 때 실행할 예약 작업이 있는지 확인한다. 방문이 적은 사이트에서는 예약 글, 업데이트 확인, 플러그인의 정리 작업이 예정 시각보다 늦어질 수 있다.
목적은 성능 개선보다 예약 작업의 실행 시각을 방문량과 분리하는 데 있다. systemd가 방문량과 상관없이 일정한 간격으로 wp-cron.php를 직접 호출하게 만드는 것이다.
이번 구성은 공개 인터넷의 /wp-cron.php URL을 거치지 않는다. Ubuntu가 WordPress 컨테이너 안에서 PHP 파일을 직접 실행한다.
service 실행 성공 기준은 Result=success, ExecMainStatus=0이다. 개별 플러그인의 예약 작업 결과까지 모두 검증했다는 뜻은 아니다. Type=oneshot 서비스는 일을 마치면 실행 중 상태를 유지하지 않으므로 inactive (dead)로 보여도 실패가 아니다.
한 줄 이상 보이면 다음 Compose 수정이 현재 볼륨에도 적용된다. 아무 줄도 보이지 않으면 wp-config.php를 수동으로 교체한 환경일 수 있다. 이 경우 WP-Cron timer는 아직 켜지 말고 5~8절의 첫 로컬 백업까지 진행한 뒤, 18절의 기존 wp-config.php에서 WORDPRESS_CONFIG_EXTRA가 안 보인다 절차를 실행한다. 그 다음 이 절의 PHP 확인과 timer 활성화로 돌아온다.
3편 Compose의 strict SITE_URL과 나머지 설정은 그대로 두고 WP_AUTO_UPDATE_CORE 다음에 보호된 설정 세 줄만 추가한다. 같은 줄이 이미 있으면 다시 넣지 않고, 기준 줄이 정확히 하나가 아닐 때도 자동 수정을 중단한다.
[SSH로 Ubuntu에서 실행]
COMPOSE_FILE=/opt/ttakdap/compose.yaml
ANCHOR=" define( 'WP_AUTO_UPDATE_CORE', 'minor' );"
if sudo grep -Fq "DISABLE_WP_CRON" "$COMPOSE_FILE"; then
echo "DISABLE_WP_CRON 설정이 이미 있습니다."
elif [[ "$(sudo grep -Fc "$ANCHOR" "$COMPOSE_FILE")" -ne 1 ]]; then
echo "기준 줄을 정확히 하나 찾지 못해 수정하지 않았습니다." >&2
false
else
if ! sudo test -e /opt/ttakdap/compose.before-wp-cron.yaml; then
sudo cp -a \
"$COMPOSE_FILE" \
/opt/ttakdap/compose.before-wp-cron.yaml
fi
COMPOSE_TMP="$(mktemp)"
trap 'rm -f -- "$COMPOSE_TMP"' EXIT HUP INT TERM
sudo awk -v anchor="$ANCHOR" '
{ print }
$0 == anchor {
print " if ( ! defined( '\''DISABLE_WP_CRON'\'' ) ) {"
print " define( '\''DISABLE_WP_CRON'\'', true );"
print " }"
}
' "$COMPOSE_FILE" > "$COMPOSE_TMP"
sudo docker compose \
--project-directory /opt/ttakdap \
--env-file /opt/ttakdap/.env \
-f "$COMPOSE_TMP" \
--profile tunnel \
config --quiet
sudo install -m 0640 -o root -g root \
"$COMPOSE_TMP" \
"$COMPOSE_FILE"
rm -f -- "$COMPOSE_TMP"
trap - EXIT HUP INT TERM
fi
unset COMPOSE_FILE ANCHOR COMPOSE_TMP
수정 결과의 앞뒤 문맥을 확인한다. DB 비밀번호나 Tunnel 토큰은 이 파일에 없으므로 출력하지 않는다.
DISABLE_WP_CRON=true가 보여야 한다. 이 값은 페이지 요청이 자동으로 Cron을 시작하는 동작을 끄는 것이지, wp-cron.php 파일을 삭제하거나 외부 URL 접근을 차단하는 설정은 아니다.
Mac 브라우저에서도 공개 홈페이지가 그대로 열리고 /wp-admin/은 3편에서 만든 Cloudflare Access 인증 뒤 로그인 화면으로 이어지는지 확인한다. 둘 중 하나라도 달라졌다면 timer를 켜기 전에 Compose 변경과 WordPress 로그부터 확인한다.
출력에 ttakdap-wp-cron.timer 한 줄, NEXT 시각, 연결된 ttakdap-wp-cron.service가 보여야 한다.
OnBootSec=5m은 부팅 5분 뒤 첫 실행, OnUnitActiveSec=5m은 service가 최근 활성화된 시점부터 5분 간격으로 호출한다. 이미 부팅 5분이 지난 뒤 timer를 처음 켜면 첫 실행이 바로 잡힐 수도 있다. 서버가 꺼져 있던 동안의 5분 간격을 여러 번 재생하지는 않는다. WordPress 큐에 남은 마감 작업은 부팅 뒤 첫 wp-cron.php 실행에서 처리한다.
5. 복구 세트에 무엇을 넣을지 먼저 정하기
백업 스크립트를 보기 전에 결과물부터 구분하면 긴 명령의 목적이 보인다.
그림 2. DB, WordPress 파일, 배포 정보와 체크섬은 같은 UTC 시각표에 둔다. 재발급하거나 따로 보관해야 하는 자격증명은 압축에서 제외한다.
파일
용도
database.sql.gz
MariaDB의 WordPress DB 논리 덤프
wordpress-files.tar.gz
/var/www/html의 WordPress 전체 파일
deployment-config.tar.gz
.env, .git, secrets를 제외한 배포 설정
compose-resolved.yaml
당시 Compose가 해석한 서비스 구조
compose-images.json
사용 중인 컨테이너 이미지 목록
manifest.txt
생성 시각, 사이트 주소, 기준 이미지 버전
SHA256SUMS
위 파일이 생성 뒤 바뀌지 않았는지 확인하는 값
DB 비밀번호, Tunnel 토큰, R2 Access Key와 crypt 복구 암호를 같은 압축에 다시 넣지 않는다. DB와 Tunnel 자격증명은 장애 뒤 재발급하고, crypt 복구 암호는 VM 밖의 비밀번호 관리자에서 가져오는 구조다.
스크립트는 먼저 .partial-UTC시각 디렉터리에서 파일을 만든다. 모든 SHA-256이 통과해야 점이 없는 UTC시각 디렉터리로 이름을 바꾼다. .partial-*는 완성된 백업이 아니다.
6. 첫 백업 전에는 자동 삭제를 끄기
R2에서 다시 내려받는 시험이 끝나기 전에는 오래된 로컬 세트를 자동 삭제하지 않는다. 현재는 LOCAL_BACKUP_RETENTION_DAYS=0으로 시작한다.
[SSH로 Ubuntu에서 실행]
bash <<'SCRIPT'
set -Eeuo pipefail
sudo install -d -m 0700 -o root -g root \
/etc/ttakdap \
/var/backups/ttakdap
if sudo test -e /etc/ttakdap/backup.env; then
echo "기존 /etc/ttakdap/backup.env가 있어 덮어쓰지 않았습니다." >&2
echo "이전 설치가 있다면 내용을 확인한 뒤 다음 단계로 진행하세요." >&2
exit 1
fi
sudo tee /etc/ttakdap/backup.env >/dev/null <<'EOF'
RCLONE_DEST=
RCLONE_CONFIG=/etc/ttakdap/rclone.conf
OFFSITE_UPLOAD_INTERVAL_HOURS=24
LOCAL_BACKUP_RETENTION_DAYS=0
EOF
sudo chmod 0600 /etc/ttakdap/backup.env
sudo chown root:root /etc/ttakdap/backup.env
sudo stat -c '%U:%G %a %n' \
/etc/ttakdap \
/etc/ttakdap/backup.env \
/var/backups/ttakdap
SCRIPT
정상 권한은 디렉터리 root:root 700, 환경 파일 root:root 600이다. backup.env에는 아직 R2 키가 없지만, root 전용 운영 파일로 처음부터 제한한다.
7. 백업 스크립트 전체를 만들기
아래 스크립트는 한 번 실행할 때 다음 순서로 움직인다.
동시에 두 백업이 실행되지 않도록 잠금 파일을 잡는다.
.partial-UTC시각 디렉터리에 MariaDB 덤프와 WordPress 파일을 만든다.
비밀값을 제외한 배포 설정과 컨테이너 이미지 정보를 기록한다.
각 파일의 SHA-256을 만들고 즉시 다시 검사한다.
모두 통과했을 때만 점이 없는 최종 디렉터리로 이름을 바꾼다.
R2가 설정돼 있고 최근 24시간 안에 성공한 전송이 없으면 암호화해 올린다.
설정한 보존 기간이 0보다 크고 검증된 R2 목적지가 있을 때만 오래된 완성 세트를 정리한다. 설정 파일을 읽지 못해도 기본값은 0이라 자동 삭제하지 않는다.
DB 덤프의 --single-transaction은 주로 WordPress가 쓰는 InnoDB 테이블을 한 시점처럼 읽고, --quick은 큰 테이블 전체를 메모리에 올리지 않고 차례로 처리한다. 다만 MyISAM 같은 비트랜잭션 테이블과 덤프 중의 ALTER, CREATE, DROP 같은 구조 변경에는 같은 일관성을 보장하지 않는다. WordPress 파일 압축도 DB 덤프 다음에 실행되므로 전체 VM을 순간 촬영한 스냅샷은 아니다.
긴 코드지만 숨겨진 설치 파일을 내려받는 방식이 아니다. 아래 내용 전체가 실제로 실행될 파일이다.
[SSH로 Ubuntu에서 실행]
sudo tee /opt/ttakdap/scripts/backup.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "Run this script as root: sudo $0" >&2
exit 1
fi
stack_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
backup_root="${BACKUP_ROOT:-/var/backups/ttakdap}"
if [[ -r /etc/ttakdap/backup.env ]]; then
# shellcheck disable=SC1091
source /etc/ttakdap/backup.env
fi
local_backup_retention_days="${LOCAL_BACKUP_RETENTION_DAYS:-0}"
if [[ ! ${local_backup_retention_days} =~ ^[0-9]+$ ]]; then
echo "LOCAL_BACKUP_RETENTION_DAYS must be a non-negative integer." >&2
exit 1
fi
offsite_upload_interval_hours="${OFFSITE_UPLOAD_INTERVAL_HOURS:-24}"
if [[ ! ${offsite_upload_interval_hours} =~ ^[0-9]+$ ]]; then
echo "OFFSITE_UPLOAD_INTERVAL_HOURS must be a non-negative integer." >&2
exit 1
fi
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
partial_dir="${backup_root}/.partial-${timestamp}"
final_dir="${backup_root}/${timestamp}"
install -m 0700 -d "${backup_root}"
# Prevent a manual run and a timer run from overlapping.
exec 9>"${backup_root}/.backup.lock"
if ! flock -n 9; then
echo "Another backup is already running." >&2
exit 1
fi
if [[ -e ${partial_dir} || -e ${final_dir} ]]; then
echo "Backup destination already exists for ${timestamp}." >&2
exit 1
fi
install -m 0700 -d "${partial_dir}"
cd "${stack_dir}"
# Create the compressed MariaDB logical dump.
echo "Creating consistent MariaDB logical dump..."
docker compose exec -T db sh -ec '
option_file="$(mktemp)"
trap '\''rm -f "${option_file}"'\'' EXIT HUP INT TERM
printf "[client]\npassword=%s\n" "$(cat /run/secrets/db_root_password)" > "${option_file}"
chmod 0600 "${option_file}"
mariadb-dump \
--defaults-extra-file="${option_file}" \
--single-transaction \
--quick \
--routines \
--events \
--triggers \
--hex-blob \
--default-character-set=utf8mb4 \
--user=root \
"${MARIADB_DATABASE}"
' | gzip -9 > "${partial_dir}/database.sql.gz"
# Archive the WordPress volume through the running container.
echo "Archiving WordPress files..."
docker compose exec -T wordpress \
tar --numeric-owner -C /var/www/html -czf - . \
> "${partial_dir}/wordpress-files.tar.gz"
# Keep deployment metadata, but leave credentials out.
echo "Archiving non-secret deployment configuration..."
tar \
--exclude='./.env' \
--exclude='./.git' \
--exclude='./secrets' \
-czf "${partial_dir}/deployment-config.tar.gz" \
.
docker compose --profile tunnel config \
> "${partial_dir}/compose-resolved.yaml"
docker compose --profile tunnel images --format json \
> "${partial_dir}/compose-images.json"
{
printf 'created_utc=%s\n' "${timestamp}"
printf 'site_url=%s\n' "$(sed -n 's/^SITE_URL=//p' .env | tail -n 1)"
printf 'wordpress_image=%s\n' 'wordpress:7.1.0-php8.4-apache'
printf 'mariadb_image=%s\n' 'mariadb:11.4.13'
printf 'cloudflared_image=%s\n' 'cloudflare/cloudflared:2026.8.2'
} > "${partial_dir}/manifest.txt"
# Record and immediately verify checksums before finalizing the set.
(
cd "${partial_dir}"
sha256sum \
database.sql.gz \
wordpress-files.tar.gz \
deployment-config.tar.gz \
compose-resolved.yaml \
compose-images.json \
manifest.txt \
> SHA256SUMS
sha256sum --check SHA256SUMS
)
mv "${partial_dir}" "${final_dir}"
echo "Local backup completed: ${final_dir}"
offsite_ready_for_pruning=0
# Upload at the configured interval when an encrypted remote exists.
if [[ -n ${RCLONE_DEST:-} ]]; then
if ! command -v rclone >/dev/null 2>&1; then
echo "RCLONE_DEST is set, but rclone is not installed." >&2
exit 1
fi
if [[ -n ${RCLONE_CONFIG:-} && ! -r ${RCLONE_CONFIG} ]]; then
echo "RCLONE_CONFIG is set, but the file is not readable." >&2
exit 1
fi
offsite_state_dir=/var/lib/ttakdap
offsite_marker="${offsite_state_dir}/last-offsite-upload"
install -m 0700 -d "${offsite_state_dir}"
should_upload=1
if [[ ${FORCE_OFFSITE_UPLOAD:-0} != 1 \
&& ${offsite_upload_interval_hours} != 0 \
&& -f ${offsite_marker} ]]; then
now_epoch=$(date +%s)
last_upload_epoch=$(stat -c %Y "${offsite_marker}")
minimum_interval_seconds=$((offsite_upload_interval_hours * 3600))
if (( now_epoch - last_upload_epoch < minimum_interval_seconds )); then
should_upload=0
fi
fi
if (( should_upload == 1 )); then
if [[ -n ${RCLONE_CONFIG:-} ]]; then
export RCLONE_CONFIG
fi
remote_dir="${RCLONE_DEST%/}/${timestamp}"
rclone copy "${final_dir}" "${remote_dir}" --immutable
rclone cryptcheck "${final_dir}" "${remote_dir}" --one-way
touch "${offsite_marker}"
offsite_ready_for_pruning=1
echo "Encrypted off-site backup verified: ${remote_dir}"
else
offsite_ready_for_pruning=1
echo "Off-site upload skipped; the last verified upload is newer than ${offsite_upload_interval_hours} hour(s)."
fi
else
echo "RCLONE_DEST is not configured; this backup exists only inside the VM." >&2
fi
# Prune only completed timestamp directories when explicitly enabled.
if [[ ${local_backup_retention_days} == 0 ]]; then
echo "Local backup pruning is disabled (LOCAL_BACKUP_RETENTION_DAYS=0)."
elif (( offsite_ready_for_pruning != 1 )); then
echo "Local backup pruning skipped; no verified off-site destination is available." >&2
else
cutoff_timestamp="$(date -u --date="${local_backup_retention_days} days ago" +%Y%m%dT%H%M%SZ)"
pruned_count=0
while IFS= read -r -d '' backup_dir; do
backup_name="${backup_dir##*/}"
if [[ ${backup_name} =~ ^[0-9]{8}T[0-9]{6}Z$ \
&& ${backup_name} < ${cutoff_timestamp} \
&& ${backup_dir} != "${final_dir}" \
&& -f ${backup_dir}/SHA256SUMS \
&& -f ${backup_dir}/manifest.txt ]]; then
rm -rf -- "${backup_dir}"
echo "Pruned expired local backup: ${backup_dir}"
((pruned_count += 1))
fi
done < <(find "${backup_root}" -mindepth 1 -maxdepth 1 -type d -print0)
echo "Local backup pruning completed: removed ${pruned_count} set(s) older than ${local_backup_retention_days} day(s)."
fi
EOF
sudo chmod 0750 /opt/ttakdap/scripts/backup.sh
sudo chown root:root /opt/ttakdap/scripts/backup.sh
sudo bash -n /opt/ttakdap/scripts/backup.sh
마지막 bash -n이 아무 메시지 없이 끝나면 셸 문법은 통과한 것이다. 이 스크립트에는 오래된 로컬 세트를 지우는 코드가 있지만, 지금은 LOCAL_BACKUP_RETENTION_DAYS=0이라 삭제 구간을 실행하지 않는다. 나중에 14일로 바꾸더라도 이름이 정확한 UTC 형식이고 SHA256SUMS와 manifest.txt가 있는 완성 세트만 대상으로 삼는다.
8. R2보다 먼저 로컬 복구 세트 만들기
첫 실행은 일부러 R2 없이 한다. DB 덤프와 파일 압축 자체가 성공하는지 먼저 나누어 보는 편이 오류를 찾기 쉽다.
[SSH로 Ubuntu에서 실행]
sudo /opt/ttakdap/scripts/backup.sh
중간에 RCLONE_DEST is not configured 경고가 보이는 것은 지금 단계에서는 정상이다. 대신 Local backup completed: 뒤에 /var/backups/ttakdap/<UTC시각>Z 형태의 경로가 보여야 한다.
최신 완성 세트를 찾아 파일을 검사한다.
sudo bash <<'EOF'
set -Eeuo pipefail
LATEST_BACKUP="$(
find /var/backups/ttakdap \
-mindepth 1 \
-maxdepth 1 \
-type d \
-regextype posix-extended \
-regex '.*/[0-9]{8}T[0-9]{6}Z' \
-printf '%f\n' |
sort |
tail -n 1
)"
if [[ -z ${LATEST_BACKUP} ]]; then
echo "완성된 백업 디렉터리를 찾지 못했습니다." >&2
exit 1
fi
cd "/var/backups/ttakdap/${LATEST_BACKUP}"
sha256sum --check SHA256SUMS
gzip -t database.sql.gz
tar -tzf wordpress-files.tar.gz >/dev/null
tar -tzf deployment-config.tar.gz >/dev/null
printf '검사한 세트: %s\n' "${LATEST_BACKUP}"
du -sh .
EOF
여섯 줄의 OK와 검사한 세트, 용량이 보이면 로컬 생성 검사는 끝났다. gzip -t와 tar -tzf는 성공하면 별도 메시지가 없다. SQL을 새 DB에 가져오거나 WordPress를 실행한 검사는 아직 아니다.
.partial-*만 남았다면 중간 단계에서 실패한 것이다. 그 디렉터리를 완성본으로 간주하지 말고, 화면에 나온 첫 오류부터 해결한다.
9. Cloudflare R2 버킷과 전용 키 만들기
이 단계만 웹브라우저에서 한다. 2026년 9월 1일 기준 Cloudflare 대시보드 메뉴 이름은 다음과 같다.
[Cloudflare 화면에서 설정]
Storage & databases → R2 → Overview로 이동한다.
Create bucket을 눌러 백업 전용 버킷을 만든다.
공개 접근은 켜지 않는다. 사용자 지정 도메인도 필요 없다.
R2 Overview의 Account Details에서 API Tokens 옆 Manage로 들어간다.
Create Account API token을 선택한다.
권한은 Object Read & Write, 범위는 Apply to specific buckets only로 정한다.
방금 만든 백업 버킷 하나만 선택해 토큰을 만든다.
Create Account API token 버튼이 보이지 않으면 계정 권한을 확인한다. Account API token 생성과 열람에는 Super Administrator 권한이 필요하다.
생성 화면에서 다음 네 값을 VM 밖 비밀번호 관리자에 저장한다.
Account ID
Access Key ID
Secret Access Key
S3 API endpoint
Secret Access Key는 생성 직후 한 번만 보일 수 있다. 이 화면은 캡처하지 않았고, 이 글의 예시에도 실제 값을 넣지 않았다. 기본 endpoint는 보통 다음 모양이다.
https://<ACCOUNT_ID>.r2.cloudflarestorage.com
EU·US·FedRAMP jurisdiction 버킷은 주소 모양이 다를 수 있다. 다음 설정에서는 Account ID를 조합하지 말고 Cloudflare 화면에 표시된 전체 S3 endpoint를 그대로 입력한다.
10. 검증한 rclone 패키지 설치하기
Ubuntu 24.04 ARM64에서 실제 확인한 rclone 1.75.0 공식 패키지를 설치한다. curl | sudo bash처럼 내려받은 내용을 바로 실행하지 않고, 파일의 SHA-256이 공식 배포값과 같은지 먼저 본다.
[SSH로 Ubuntu에서 실행]
dpkg --print-architecture
결과가 arm64일 때만 아래 블록을 사용한다. amd64가 보이면 이 패키지가 맞지 않으므로 중단해야 한다.
bash <<'SCRIPT'
set -Eeuo pipefail
if [[ $(dpkg --print-architecture) != arm64 ]]; then
echo "이 설치 블록은 Ubuntu ARM64 전용입니다." >&2
exit 1
fi
RCLONE_TMP="$(mktemp -d)"
trap 'rm -rf -- "${RCLONE_TMP}"' EXIT HUP INT TERM
curl --fail --location --show-error \
--output "${RCLONE_TMP}/rclone.deb" \
https://downloads.rclone.org/v1.75.0/rclone-v1.75.0-linux-arm64.deb
printf '%s %s\n' \
'081c605cbf47ade3114e78db2130766a23281a12b79cdd5774b11dad6c6c0931' \
"${RCLONE_TMP}/rclone.deb" |
sha256sum --check
sudo dpkg --install "${RCLONE_TMP}/rclone.deb"
rclone version
SCRIPT
rclone.deb: OK와 rclone v1.75.0이 보여야 한다. SHA-256이 다르면 설치하지 않고 내려받은 파일을 폐기한다.
11. R2 위에 rclone crypt 원격 저장소 만들기
R2 자체도 서버 측 암호화를 제공하지만 키는 Cloudflare가 관리한다. 여기서는 그 위에 rclone crypt를 얹어 VM에서 파일 내용과 이름을 암호화한 다음 올린다.
아래 블록은 민감한 값을 화면에 보이지 않게 입력받고, 임시 설정으로 버킷 접근을 시험한 뒤 root만 읽을 수 있는 최종 파일을 만든다. 키와 암호를 명령행 인수에 쓰지 않으므로 셸 기록에도 남지 않는다.
[SSH로 Ubuntu에서 실행]
bash <<'SCRIPT'
set -Eeuo pipefail
OLD_UMASK="$(umask)"
umask 077
CONFIG_TMP="$(mktemp)"
cleanup() {
rm -f -- "${CONFIG_TMP}"
unset R2_ENDPOINT R2_BUCKET R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY
unset CRYPT_PASSWORD CRYPT_PASSWORD_CONFIRM CRYPT_PASSWORD_OBSCURED
umask "${OLD_UMASK}"
}
trap cleanup EXIT HUP INT TERM
read -r -p 'Cloudflare S3 endpoint 전체 주소: ' R2_ENDPOINT </dev/tty
read -r -p 'R2 백업 버킷 이름: ' R2_BUCKET </dev/tty
read -r -s -p 'R2 Access Key ID: ' R2_ACCESS_KEY_ID </dev/tty
printf '\n'
read -r -s -p 'R2 Secret Access Key: ' R2_SECRET_ACCESS_KEY </dev/tty
printf '\n'
read -r -s -p 'crypt 복구 암호: ' CRYPT_PASSWORD </dev/tty
printf '\n'
read -r -s -p 'crypt 복구 암호 다시 입력: ' CRYPT_PASSWORD_CONFIRM </dev/tty
printf '\n'
R2_ENDPOINT="${R2_ENDPOINT%/}"
if [[ ! ${R2_ENDPOINT} =~ ^https://[a-zA-Z0-9.-]+\.r2\.cloudflarestorage\.com$ ]]; then
echo "Cloudflare 화면의 전체 S3 endpoint를 다시 확인하세요." >&2
exit 1
fi
if [[ ! ${R2_BUCKET} =~ ^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$ ]]; then
echo "버킷 이름 형식을 다시 확인하세요." >&2
exit 1
fi
if [[ -z ${R2_ACCESS_KEY_ID} || -z ${R2_SECRET_ACCESS_KEY} ]]; then
echo "R2 자격증명은 비워 둘 수 없습니다." >&2
exit 1
fi
if [[ ${#CRYPT_PASSWORD} -lt 20 ]]; then
echo "crypt 복구 암호는 20자 이상으로 만드세요." >&2
exit 1
fi
if [[ ${CRYPT_PASSWORD} != "${CRYPT_PASSWORD_CONFIRM}" ]]; then
echo "crypt 복구 암호가 서로 다릅니다." >&2
exit 1
fi
CRYPT_PASSWORD_OBSCURED="$(printf '%s' "${CRYPT_PASSWORD}" | rclone obscure -)"
cat >"${CONFIG_TMP}" <<EOF
[ttakdap-r2]
type = s3
provider = Cloudflare
access_key_id = ${R2_ACCESS_KEY_ID}
secret_access_key = ${R2_SECRET_ACCESS_KEY}
region = auto
endpoint = ${R2_ENDPOINT}
no_check_bucket = true
[ttakdap-r2-crypt]
type = crypt
remote = ttakdap-r2:${R2_BUCKET}
filename_encryption = standard
directory_name_encryption = true
password = ${CRYPT_PASSWORD_OBSCURED}
EOF
chmod 0600 "${CONFIG_TMP}"
access_ready=0
for attempt in {1..12}; do
if rclone --config "${CONFIG_TMP}" \
lsf "ttakdap-r2:${R2_BUCKET}" \
--max-depth 1 >/dev/null 2>&1; then
access_ready=1
break
fi
if (( attempt < 12 )); then
sleep 5
fi
done
if (( access_ready != 1 )); then
echo "R2 버킷 권한을 확인하지 못했습니다." >&2
echo "endpoint, 버킷 범위, Object Read & Write 권한을 확인하세요." >&2
exit 1
fi
sudo install -d -m 0700 -o root -g root /etc/ttakdap
sudo install -m 0600 -o root -g root \
"${CONFIG_TMP}" \
/etc/ttakdap/rclone.conf
sudo stat -c '%U:%G %a %n' /etc/ttakdap/rclone.conf
echo 'R2 연결과 root 전용 설정 저장 완료'
SCRIPT
Secret Access Key와 crypt 암호를 입력할 때 화면에 글자나 별표가 안 나오는 것이 정상이다. 입력 뒤 Enter를 누르면 다음 질문으로 넘어간다.
root:root 600 /etc/ttakdap/rclone.conf가 보이면 권한이 맞다. rclone obscure는 crypt 암호를 설정 파일에서 바로 읽기 어렵게 바꾸지만, 비밀번호 관리자처럼 안전한 암호 저장소는 아니다. 설정 파일에는 R2 Secret Access Key도 있으므로 0600 권한이 중요하다.
crypt 복구 암호는 VM 밖 비밀번호 관리자에 별도로 보관한다. VM과 설정 파일을 모두 잃으면 R2 API 키는 다시 만들 수 있어도 이 암호 없이 기존 백업 내용을 복호화할 수 없다.
12. R2 전송을 켜고 첫 암호화 업로드 확인하기
이제 backup.env의 원격 목적지만 채운다. 로컬 자동 삭제는 아직 0일 그대로 둔다.
[SSH로 Ubuntu에서 실행]
bash <<'SCRIPT'
set -Eeuo pipefail
if [[ "$(sudo grep -c '^RCLONE_DEST=$' /etc/ttakdap/backup.env)" -ne 1 ]]; then
echo "빈 RCLONE_DEST 줄을 정확히 하나 찾지 못했습니다." >&2
exit 1
else
sudo sed -i \
's/^RCLONE_DEST=$/RCLONE_DEST=ttakdap-r2-crypt:/' \
/etc/ttakdap/backup.env
fi
sudo chmod 0600 /etc/ttakdap/backup.env
sudo chown root:root /etc/ttakdap/backup.env
sudo env FORCE_OFFSITE_UPLOAD=1 \
/opt/ttakdap/scripts/backup.sh
SCRIPT
처음에는 파일 크기에 따라 시간이 걸린다. 마지막에 Encrypted off-site backup verified:가 보이면 rclone copy와 rclone cryptcheck --one-way가 모두 통과한 것이다. --one-way는 로컬 원본 파일이 원격에 맞게 존재하는지 확인하며 원격에만 있는 추가 객체까지 오류로 보지는 않는다. timestamp별 새 디렉터리에만 복사하는 현재 구조와 맞는다. Cloudflare R2 화면에서 알아보기 어려운 파일명과 디렉터리명이 보이는 것도 crypt가 적용된 정상 모습이다.
여기서 사용한 --immutable은 같은 대상 파일이 같으면 건너뛰고, 기존 파일이 달라졌으면 수정하지 않고 실패하는 옵션이다. Cloudflare Bucket lock처럼 규칙이 유지되는 동안 객체 삭제·덮어쓰기 요청을 차단하는 기능은 아니다.
13. R2에서 다시 내려받아 같은 파일인지 확인하기
업로드 성공 문구만으로는 읽기 경로를 확인할 수 없다. 최신 세트를 crypt 원격 저장소에서 새 임시 디렉터리로 내려받고, 로컬 원본과 체크섬 파일이 같은지 비교한 뒤 압축 파일까지 연다.
그림 3. 이번 편은 6단계의 R2 왕복 검사까지 진행한다. 빈 VM이나 격리 스택에서의 실제 전체 복원은 별도 단계로 남긴다.
[SSH로 Ubuntu에서 실행]
sudo bash <<'EOF'
set -Eeuo pipefail
LATEST_BACKUP="$(
rclone \
--config /etc/ttakdap/rclone.conf \
lsf ttakdap-r2-crypt: \
--dirs-only \
--max-depth 1 |
sed 's:/$::' |
awk '/^[0-9]{8}T[0-9]{6}Z$/ { print }' |
sort |
tail -n 1
)"
if [[ -z ${LATEST_BACKUP} ]]; then
echo "R2에서 완성된 백업 디렉터리를 찾지 못했습니다." >&2
exit 1
fi
if [[ ! -f /var/backups/ttakdap/${LATEST_BACKUP}/SHA256SUMS ]]; then
echo "같은 세트의 로컬 SHA256SUMS를 찾지 못했습니다." >&2
echo "강제 업로드 직후 이 검사를 다시 실행하세요." >&2
exit 1
fi
RESTORE_TEST_DIR="$(mktemp -d /var/tmp/ttakdap-r2-test.XXXXXX)"
cleanup() {
if [[ ${RESTORE_TEST_DIR} == /var/tmp/ttakdap-r2-test.* \
&& -d ${RESTORE_TEST_DIR} ]]; then
rm -rf -- "${RESTORE_TEST_DIR}"
fi
}
trap cleanup EXIT HUP INT TERM
rclone \
--config /etc/ttakdap/rclone.conf \
copy \
"ttakdap-r2-crypt:${LATEST_BACKUP}" \
"${RESTORE_TEST_DIR}" \
--immutable
cmp \
"/var/backups/ttakdap/${LATEST_BACKUP}/SHA256SUMS" \
"${RESTORE_TEST_DIR}/SHA256SUMS"
cd "${RESTORE_TEST_DIR}"
sha256sum --check SHA256SUMS
gzip -t database.sql.gz
tar -tzf wordpress-files.tar.gz >/dev/null
tar -tzf deployment-config.tar.gz >/dev/null
printf 'R2 재다운로드 검사 통과: %s\n' "${LATEST_BACKUP}"
EOF
여섯 줄의 OK와 R2 재다운로드 검사 통과가 보이면 다음을 실제로 확인한 것이다.
해당 버킷을 읽을 수 있다.
VM 밖 R2에 암호화된 객체가 존재한다.
저장해 둔 crypt 암호로 파일명과 내용을 복호화할 수 있다.
내려받은 파일의 SHA-256이 업로드 전 값과 같다.
DB gzip과 두 tar 파일의 목록을 읽을 수 있다.
임시 시험 디렉터리는 성공과 실패 모두에서 정리된다. 위 블록의 /var/tmp/ttakdap-r2-test.* 안전 조건을 다른 넓은 경로로 바꾸지 않는다.
이 결과만으로 SQL이 새 MariaDB에 정상 import되거나 플러그인과 파일 권한까지 정상이라는 것은 알 수 없다. 운영 WordPress에 덮어써서 시험하지 말고, 다음 단계에서는 별도 VM이나 격리된 새 볼륨을 이용한 전체 복원 절차를 따로 만들어야 한다.
14. 왕복 검증 뒤에만 로컬 14일 보존 켜기
R2에서 다시 내려받는 데 성공했으므로 이제 자동 정리를 켠다. 정확히 0으로 된 한 줄만 14로 바꾸고 결과를 확인한다.
[SSH로 Ubuntu에서 실행]
if [[ "$(sudo grep -c '^LOCAL_BACKUP_RETENTION_DAYS=0$' /etc/ttakdap/backup.env)" -ne 1 ]]; then
echo "LOCAL_BACKUP_RETENTION_DAYS=0 줄을 정확히 하나 찾지 못했습니다." >&2
false
else
sudo sed -i \
's/^LOCAL_BACKUP_RETENTION_DAYS=0$/LOCAL_BACKUP_RETENTION_DAYS=14/' \
/etc/ttakdap/backup.env
fi
sudo grep '^LOCAL_BACKUP_RETENTION_DAYS=' \
/etc/ttakdap/backup.env
6시간마다 전부 성공하면 14일 동안 대략 56세트가 남지만, 실행 지연이나 실패에 따라 실제 개수는 달라진다. R2에는 24시간 marker가 있어 하루 최대 한 세트만 추가한다. 현재 구성은 R2의 오래된 객체를 자동 삭제하지 않는다.
15. 백업 service와 timer 만들기
로컬 생성, R2 업로드, 재다운로드를 모두 수동으로 확인한 뒤에 자동 실행을 만든다. service는 앞에서 만든 스크립트를 root로 한 번 실행하고, 파일 권한은 UMask=0077로 제한한다.
Result=success, ExecMainStatus=0과 새 로컬 경로가 보여야 한다. 최근 24시간 안에 R2 업로드를 이미 성공했다면 이번 로그에는 Off-site upload skipped가 나올 수 있다. 오류가 아니라 일반 자동 실행에서는 하루 최대 한 세트만 보내도록 한 조건이 작동한 것이다.
반대로 로컬 백업 뒤 R2 전송에서 실패하면 로컬 세트는 남아 있어도 service 결과는 실패한다. Local backup completed 한 줄만 보고 정상으로 판단하지 않고, journal의 끝과 마지막 R2 성공 시각을 기록한 /var/lib/ttakdap/last-offsite-upload 파일을 같이 본다.
그림 4. 실제 서버에서는 두 timer가 enabled·active였고, 최근 WP-Cron과 백업 service가 종료코드 0으로 끝난 것을 확인했다.
백업 예약 시각은 VM의 Asia/Seoul 기준으로 00시·06시·12시·18시 20분이다. RandomizedDelaySec=10m과 systemd의 기본 정확도 여유를 합치면 설정상 약 11분의 지연 창이 더해진다. 시스템이 멈췄거나 과부하인 상황까지 포함한 절대 상한은 아니다. 반면 백업 폴더 끝의 Z는 UTC 시각이라 화면 시각과 다르게 보인다.
백업 timer의 Persistent=true는 예약 시각에 VM이 꺼져 있었다면 부팅 뒤 놓친 작업을 한 번 실행한다. 여러 회를 놓쳤다고 백업 여러 개를 연달아 만들지는 않는다.
16. 일주일에 한 번 볼 운영 상태
자동화는 설치한 날보다 조용히 실패한 날이 문제다. 매주 한 번은 timer, 최근 종료 결과, 백업 디렉터리, 디스크를 함께 본다.
다음 실행 시각만 있으면 충분하지 않다. 아래 중 하나면 자동화를 정상으로 간주하지 않고 원인부터 확인한다.
timer 표에 NEXT 시각이 없다.
최근 service가 Result=success, ExecMainStatus=0이 아니다.
가장 최근 완성 백업이 8시간보다 오래됐다.
루트 디스크 사용량이 80%를 넘었거나 다음 전체 세트를 만들 여유가 부족하다.
한 달에 한 번은 sudo env FORCE_OFFSITE_UPLOAD=1 /opt/ttakdap/scripts/backup.sh로 새 세트를 올린 직후 13절의 R2 재다운로드 검사를 다시 실행하는 편이 좋다. 그래야 원격 최신 세트와 비교할 로컬 원본이 확실히 남아 있다. crypt 암호를 비밀번호 관리자에서 꺼내 새 rclone 설정을 재구성하는 시험과 빈 VM 전체 복원은 더 강한 검증이지만, 운영 사이트와 분리한 환경에서 진행해야 한다.
17. 선택 사항: R2 Bucket lock은 시험 뒤에 켜기
Cloudflare R2에는 일정 기간 객체의 삭제와 덮어쓰기를 막는 Bucket lock이 있다.
규칙이 유지되는 동안 객체 삭제·덮어쓰기 요청을 막아 운영 실수나 제한된 R2 객체 자격증명 유출의 위험을 줄일 수 있다. 다만 Cloudflare 관리자 권한을 장악한 공격자는 lock 규칙 자체를 제거할 수 있으므로 절대적인 WORM 보관은 아니다. 잘못 설정하면 그 기간 동안 테스트 객체와 큰 백업도 삭제할 수 있으니 저장 비용과 보존 기간을 먼저 확인하고, 작은 별도 테스트 버킷에서 규칙을 시험한 뒤 적용한다.
Bucket lock을 적용하면 lifecycle 삭제보다 잠금 기간이 우선한다. 현재 이 글의 스크립트에는 R2 원격 삭제나 일간·주간·월간 선별 보존 로직이 없으므로, --immutable만으로 장기 불변 보존이 완료됐다고 보지 않는다.
18. 막혔을 때 확인할 것
WP-Cron service가 아무 출력 없이 끝난다
실행할 예약 작업이 없으면 정상이어도 출력이 없다. systemctl show의 Result=success, ExecMainStatus=0은 service 실행 자체의 성공 기준이다. 각 플러그인이 예약한 업무 결과는 해당 플러그인 로그를 별도로 확인해야 한다.
systemctl show ttakdap-wp-cron.service \
-p Result \
-p ExecMainStatus
service가 inactive (dead)로 보인다
두 service 모두 Type=oneshot이다. 한 번의 일을 성공적으로 끝낸 뒤 inactive로 돌아가는 것이 정상이다. 계속 active여야 하는 것은 .service가 아니라 .timer다.
기존 wp-config.php에서 WORDPRESS_CONFIG_EXTRA가 안 보인다
수동으로 교체한 wp-config.php일 수 있다. 먼저 8절의 로컬 복구 세트를 하나 만든 뒤, 현재 파일 안에서 /* That's all, stop editing! 기준 줄을 정확히 하나 찾고 보호된 설정을 삽입한다.
[SSH로 Ubuntu에서 실행]
sudo docker compose \
--env-file /opt/ttakdap/.env \
-f /opt/ttakdap/compose.yaml \
--profile tunnel \
exec -T wordpress \
sh -ec '
file=/var/www/html/wp-config.php
anchor="stop editing!"
if grep -q "DISABLE_WP_CRON" "$file"; then
echo "DISABLE_WP_CRON 설정이 이미 있습니다."
exit 0
fi
if [ "$(grep -Fc "$anchor" "$file")" -ne 1 ]; then
echo "wp-config.php 기준 줄을 정확히 하나 찾지 못했습니다." >&2
exit 1
fi
tmp="$(mktemp /var/www/html/.wp-config.php.XXXXXX)"
trap '\''rm -f "$tmp"'\'' EXIT HUP INT TERM
awk -v anchor="$anchor" '\''
index($0, anchor) > 0 {
print "if ( ! defined( \047DISABLE_WP_CRON\047 ) ) {"
print " define( \047DISABLE_WP_CRON\047, true );"
print "}"
print ""
}
{ print }
'\'' "$file" > "$tmp"
php -l "$tmp"
chown --reference="$file" "$tmp"
chmod --reference="$file" "$tmp"
mv -f -- "$tmp" "$file"
php -l "$file"
'
이 블록은 같은 WordPress 볼륨의 숨김 임시 파일에서 결과를 먼저 php -l로 검사한다. 통과하면 기존 소유권과 권한을 복사한 뒤 원자적으로 교체하며, 시작 전에 만든 로컬 복구 세트가 되돌리기 사본 역할을 한다. No syntax errors detected가 보인 뒤 4절의 PHP 확인 명령에서 DISABLE_WP_CRON=true가 나오는지 본다. Compose의 보호된 설정도 그대로 남겨 두면 새 볼륨에서 설정을 다시 만들 때 적용된다.
부팅 뒤 WP-Cron이 한 번 실패한다
Docker는 시작됐지만 WordPress가 아직 healthy가 아닐 수 있다. 다음 실행이 성공했는지 journal을 확인한다. 반복된다면 컨테이너 상태와 DB 연결부터 본다.
Account ID를 조합해 만든 주소 대신 Cloudflare에 표시된 전체 endpoint를 썼는지, 토큰 권한이 Object Read & Write인지, 정확한 백업 버킷 하나가 선택됐는지 확인한다. jurisdiction 버킷은 endpoint 모양이 다를 수 있다.
키를 다시 만들었으면 /etc/ttakdap/rclone.conf를 화면에 출력해 고치지 않는다. 11절의 입력 블록을 다시 실행해 root 전용 파일을 교체한다. R2 API 키만 교체하는 경우에는 비밀번호 관리자에 저장한 기존 crypt 복구 암호를 그대로 입력해야 예전 백업도 계속 읽을 수 있다.
R2에서 이름을 읽을 수 없다
filename_encryption = standard와 디렉터리명 암호화가 적용된 정상 모습이다. Cloudflare 화면의 암호문 이름을 손으로 바꾸지 않는다. 파일 확인과 다운로드에는 항상 ttakdap-r2-crypt: 원격 이름을 쓴다.
로컬 백업은 생겼는데 service는 실패한다
로컬 생성 뒤 R2 업로드나 cryptcheck에서 실패했을 수 있다. 가장 최근 완성 세트와 journal 끝부분, 마지막 R2 성공 시각 파일을 함께 본다. 실패한 상태에서 보존 기간이나 원격 파일을 임의로 지우지 않는다.
이 명령은 기존 로컬 복구 세트, R2 객체, WordPress 데이터 볼륨을 지우지 않는다.
WP-Cron은 DISABLE_WP_CRON=true인 상태에서 timer부터 끄면 예약 작업이 전부 멈출 수 있다. 원래의 방문 요청 방식으로 되돌릴 때는 다음 순서를 지킨다.
/opt/ttakdap/compose.yaml과, 수동으로 수정했다면 컨테이너의 wp-config.php에서 이 글이 추가한 DISABLE_WP_CRON 보호 블록만 제거한다.
PHP·Compose 문법을 확인하고 WordPress를 재생성한다.
4절의 PHP 확인 명령을 반대로 확인해 DISABLE_WP_CRON=false가 나온 뒤에만 sudo systemctl disable --now ttakdap-wp-cron.timer를 실행한다.
자동 백업만 잠시 멈추는 상황이라면 WP-Cron 설정을 되돌릴 필요는 없다.
진단 중에는 docker compose down -v, 볼륨 삭제, rclone sync, rclone purge를 사용하지 않는다. 모두 현재 데이터나 원격 백업을 지울 수 있어 롤백 명령이 아니다.
20. 4편 완료 점검표
☐ WP-Cron service 수동 실행이 Result=success, 종료코드 0이다.
☐ 실제 PHP에서 DISABLE_WP_CRON=true가 확인된다.
☐ WordPress 재생성 뒤 공개 홈페이지와 관리자 경로가 정상이다.
☐ WP-Cron timer가 enabled·active이고 다음 실행 시각이 보인다.
☐ 로컬 복구 세트에서 SHA-256, gzip, tar 검사가 모두 통과한다.
☐ R2 토큰은 백업 버킷 하나에만 Object Read & Write 권한이 있다.
☐ /etc/ttakdap/rclone.conf가 root:root 600이다.
☐ crypt 복구 암호를 VM 밖 비밀번호 관리자에 저장했다.
☐ 강제 R2 업로드와 cryptcheck가 통과했다.
☐ R2 사본을 다시 내려받아 SHA-256과 압축 파일을 검사했다.
☐ 그 뒤에만 로컬 보존 기간을 14일로 바꿨다.
☐ 백업 timer가 enabled·active이고 최근 service 결과도 성공이다.
☐ 가장 최근 완성 백업 시각과 VM 디스크 여유를 확인했다.
☐ 이번 검증이 빈 VM 전체 복원 성공은 아니라는 범위를 구분했다.
여기까지 구축하고 나서 달라진 점
1편의 VM, 2편의 내부 WordPress, 3편의 Tunnel 공개에 이어 4편에서는 사람이 접속하지 않는 시간의 운영까지 붙였다. 특히 백업은 파일이 있다보다 다시 읽을 수 있다를 기준으로 보니 확인해야 할 단계가 분명해졌다.
가장 안심된 순간은 R2 화면에 객체가 보였을 때가 아니라, 다시 내려받은 세트에서 여섯 파일의 OK가 확인됐을 때였다.
아직 남은 가장 큰 과제는 새 Ubuntu VM에서의 전체 복원 연습이다. 같은 버전의 MariaDB에 SQL을 가져오고, 새 WordPress 볼륨에 파일을 푼 뒤 대표 글·이미지·관리자 로그인을 확인해야 비로소 실제 복원 절차라고 부를 수 있다. 운영 서버에 바로 덮어쓰지 않고 별도 환경에서 진행할 내용이다.
2편을 마쳤을 때 MariaDB와 WordPress 컨테이너는 둘 다 healthy였지만 브라우저에서는 사이트가 열리지 않았다. 실패한 것이 아니라 의도한 상태였다. WordPress의 80번 포트를 Ubuntu나 공유기에 공개하지 않았기 때문이다.
이번에는 그 상태를 그대로 유지한 채 cloudflared 컨테이너만 추가한다. 공유기에서 80·443 포트를 열어 외부 요청을 직접 받는 대신, cloudflared가 Cloudflare 쪽으로 먼저 연결하고 방문 요청을 WordPress에 전달하게 한다.
가장 신경 쓴 부분은 Tunnel을 켜는 명령보다 WordPress 초기 설치 화면을 언제 공개할지였다. 공개 주소부터 만든 다음 관리자 계정을 천천히 설정하면, 그 사이 초기 설치 화면이 인터넷에 노출될 수 있다. 그래서 먼저 사이트 전체에 임시 Cloudflare Access를 만들고, WordPress 설치가 끝난 뒤 공개 글과 관리자 경로를 분리했다.
그림 1. 방문자는 Cloudflare를 거쳐 접속하고, Ubuntu 안의 cloudflared가 이미 만들어 둔 연결을 통해 WordPress로 요청을 전달한다. MariaDB는 외부 요청 경로에 연결하지 않는다.
이번 글을 끝내면 다음 상태가 된다.
https://내도메인으로 WordPress가 열린다.
공유기·Mac·Ubuntu에 웹 포트포워딩을 만들지 않는다.
Tunnel 토큰은 명령줄이나 .env가 아니라 권한이 제한된 파일에 저장된다.
WordPress 초기 설치 중에는 사이트 전체를 소유자만 볼 수 있다.
설치 후 홈페이지와 글은 로그인 없이 공개된다.
/wp-login.php, /wp-admin, /wp-admin/*는 Cloudflare Access 인증을 먼저 거친다.
/wp-admin/admin-ajax.php는 기본적으로 보호하고, 로그인하지 않은 공개 기능이 실제로 이 경로를 써야 할 때만 정확한 경로에 Access 예외를 둔다.
여기서 Access는 브라우저로 들어오는 관리자 입구에 한 겹을 더하는 장치다. WordPress 계정과 비밀번호를 대신하지 않으며, XML-RPC나 REST API처럼 별도 경로를 사용하는 모든 인증 요청까지 자동으로 보호하는 구성도 아니다.
전체 구조와 이 방식을 선택한 이유는 맥미니를 24시간 홈서버로 써도 될까?에서 먼저 볼 수 있다. Ubuntu VM과 Docker 구성이 아직 없다면 1편과 2편부터 진행해야 한다.
이 글은 2026년 9월 1일의 Cloudflare 화면과 실제 구성에서 사용한 cloudflare/cloudflared:2026.8.2를 기준으로 한다. Cloudflare 메뉴 이름은 계정별 UI 전환 시점에 따라 조금 다를 수 있고, 컨테이너 버전은 나중에 바뀔 수 있다.
시작 전에 확인할 조건
이 글은 2편의 완료 상태를 전제로 한다.
Ubuntu Server 24.04 LTS ARM64 VM이 실행 중이다.
Mac에서 Ubuntu로 SSH 키 접속이 된다.
/opt/ttakdap에 .env, compose.yaml, config, secrets가 있다.
db와 wordpress 컨테이너가 모두 healthy다.
wordpress와 db에 호스트 공개 포트가 없다.
사용할 도메인이 Cloudflare 계정에 추가돼 있고 Zone 상태가 Active다.
Cloudflare DNS를 쓰도록 도메인의 네임서버 변경이 끝났다.
공유기와 Ubuntu에 80·443 포트포워딩을 만들지 않았다.
도메인 상태가 아직 Pending이면 Tunnel 설정보다 도메인 등록기관의 네임서버부터 확인한다. 기존 DNS 레코드를 정리할 때는 같은 호스트명의 A·AAAA·CNAME 충돌만 살펴보고, 메일에 쓰는 MX·TXT 레코드는 용도를 모른 채 지우면 안 된다.
작업 시간은 Cloudflare 계정과 도메인이 준비된 상태에서 약 45~75분으로 잡는 편이 좋다. 인증서나 DNS 반영 시간은 계정과 도메인 상태에 따라 달라질 수 있다.
먼저 구분할 것: Cloudflare 화면도 두 군데다
이번 작업에서는 Ubuntu 터미널뿐 아니라 Cloudflare 화면 위치도 헷갈리기 쉽다. Tunnel과 Access가 서로 다른 메뉴에 있기 때문이다.
일부 계정에서는 Tunnel이 Zero Trust의 Networks → Connectors에 보이거나, Published application이 이전 이름인 Public Hostnames로 보일 수 있다. 메뉴 이름이 조금 달라도 원격 관리 Tunnel, Published application, Self-hosted Access application이라는 기능을 찾으면 된다.
Cloudflare의 세 기능도 역할이 다르다.
기능
하는 일
하지 않는 일
DNS
도메인 요청을 Cloudflare로 안내
WordPress 컨테이너에 직접 연결하지 않음
Tunnel
Cloudflare와 cloudflared 사이 연결로 요청을 운반
누가 관리자 화면에 들어갈지 결정하지 않음
Access
특정 사용자·경로의 통과 여부를 결정
WordPress 자체 관리자 비밀번호를 대신하지 않음
Access를 통과한 뒤 WordPress 로그인 화면이 다시 나오는 것은 정상이다. Cloudflare Access와 WordPress 계정은 서로 다른 두 단계다.
명령을 입력하는 화면 확인하기
코드 블록 위에 실행 장소를 표시한다.
글의 표시
명령을 입력하는 화면
명령이 적용되는 곳
Mac에서 실행
macOS 터미널
외부 주소 점검
SSH로 Ubuntu에서 실행
Mac 터미널에서 Ubuntu에 접속한 화면
Ubuntu VM과 Docker
Cloudflare 화면에서 설정
웹브라우저의 Cloudflare 대시보드
DNS·Tunnel·Access
지금 터미널 위치가 헷갈리면 먼저 확인한다.
uname -s
pwd
Linux와 /home/... 또는 /opt/...가 보이면 Ubuntu다. 이 글의 Docker 명령은 Linux가 표시되는 SSH 화면에서 실행한다.
sudo 비밀번호나 Tunnel 토큰을 입력할 때 화면에 글자나 별표가 보이지 않는 것은 정상이다. 비밀번호와 토큰을 채팅창에 보내거나 코드 블록 뒤에 붙이면 안 된다.
0. 공개 전 상태와 복구 지점 확인하기
Tunnel을 추가하기 전에 2편의 내부 구동 상태를 다시 확인한다. 이 단계가 통과해야 나중에 문제가 생겼을 때 Tunnel 쪽과 WordPress 쪽을 나눠 볼 수 있다.
db와 wordpress가 모두 healthy여야 한다. Restarting, unhealthy, Exit가 보이면 Tunnel을 추가하지 말고 2편의 상태 검사부터 다시 확인한다.
호스트에 공개된 포트가 없는지도 다시 확인한다.
[SSH로 Ubuntu에서 실행]
PUBLISHED_PORTS="$(
for CONTAINER_ID in $(
sudo docker compose \
--env-file /opt/ttakdap/.env \
-f /opt/ttakdap/compose.yaml \
ps -q db wordpress
); do
sudo docker port "$CONTAINER_ID"
done
)"
if [[ -z "$PUBLISHED_PORTS" ]]; then
echo "호스트 공개 포트 없음"
else
echo "확인 필요한 포트 매핑:"
printf '%s\n' "$PUBLISHED_PORTS"
fi
unset PUBLISHED_PORTS CONTAINER_ID
정상 결과는 호스트 공개 포트 없음이다.
Compose 원본도 별도로 보관한다. 같은 이름의 백업을 덮어쓰지 않도록 한 번만 만든다.
[SSH로 Ubuntu에서 실행]
if sudo test -e /opt/ttakdap/compose.before-cloudflared.yaml; then
echo "기존 Compose 백업을 그대로 보존합니다."
else
sudo cp -a \
/opt/ttakdap/compose.yaml \
/opt/ttakdap/compose.before-cloudflared.yaml
echo "Tunnel 추가 전 Compose 백업 완료"
fi
Parallels에서는 Ubuntu를 정상 종료한 뒤 2편 내부 구동 완료 같은 이름으로 스냅샷을 하나 더 만들어 두면 좋다. Compose 백업은 설정 파일만 되돌리고, VM 스냅샷은 Ubuntu 전체 상태를 되돌린다는 차이가 있다.
1. 공개 주소보다 임시 Access를 먼저 만들기
Cloudflare 공식 안내도 공개 호스트를 Tunnel에 연결하기 전에 Access 애플리케이션을 먼저 만들 것을 권장한다. Published application을 저장하는 순간 도메인이 인터넷에서 열릴 수 있기 때문이다.
이 단계에서는 WordPress 관리자 경로만 고르는 것이 아니라 사이트 전체를 임시로 보호한다. 아직 WordPress 관리자 계정을 만들지 않았기 때문이다.
One-time PIN 로그인 준비
Google Workspace나 Microsoft Entra ID 같은 별도 로그인 공급자를 이미 연결했다면 그 IdP를 사용해도 된다. 개인 사이트에서 이메일로 일회용 코드를 받을 경우에는 Zero Trust에서 다음 위치를 확인한다.
[Cloudflare 화면에서 설정]
Zero Trust → Integrations → Identity providers로 이동한다.
Add new identity provider를 누른다.
One-time PIN을 추가한다.
2026년의 새 Zero Trust 조직에는 OTP가 자동으로 추가되지 않을 수 있다. Access 애플리케이션에서 로그인 방식 목록에 One-time PIN이 없다면 이 단계부터 진행한다.
사이트 전체 임시 보호 만들기
[Cloudflare 화면에서 설정]
Zero Trust → Access controls → Applications로 이동한다.
Create new application을 누른다.
Self-hosted and private를 선택한다.
이름을 WordPress 초기 설치 임시 보호처럼 알아보기 쉽게 적는다.
Add public hostname에서 사용할 도메인을 선택한다.
사이트 전체가 대상이 되도록 Path는 비워 둔다.
정책 Action은 Allow로 둔다.
Include는 Emails를 선택하고 실제 소유자 이메일을 정확히 입력한다.
로그인 방식으로 One-time PIN 또는 사용 중인 IdP를 선택한다.
애플리케이션을 저장한다.
Include → Login Methods → One-time PIN만 넣으면 “OTP를 쓸 수 있는 모든 유효한 이메일”을 허용하는 넓은 정책이 될 수 있다. 반드시 Emails에 허용할 주소를 정확히 지정한다.
아직 Published application을 만들지 않았으므로 도메인이 열리지 않아도 정상이다. 이 단계의 목표는 외부 연결이 생기는 순간 먼저 적용될 잠금장치를 준비하는 것이다.
2. 원격 관리 Named Tunnel 만들기
이번 구성은 Cloudflare 대시보드에서 설정을 관리하는 remotely-managed Tunnel을 사용한다. cloudflared tunnel login, 로컬 config.yml, Tunnel UUID의 JSON 자격 증명을 사용하는 locally-managed 절차와 섞지 않는다.
[Cloudflare 화면에서 설정]
일반 Cloudflare 대시보드의 Networking → Tunnels로 이동한다.
Create a tunnel을 누른다.
Tunnel 이름을 wordpress-home-server처럼 용도가 보이게 적는다.
Tunnel을 만든 뒤 실행 환경에서 Docker를 선택한다.
화면에 표시된 Docker 실행 명령은 아직 실행하지 않는다.
Docker 명령 안의 --token 뒤에 있는 긴 eyJ... 값이 Tunnel 토큰이다. 이 토큰을 가진 사람은 해당 Tunnel의 connector를 실행할 수 있다.
화면 전체를 캡처하지 않는다.
토큰을 채팅이나 메모 앱에 붙이지 않는다.
.env나 compose.yaml에 직접 적지 않는다.
셸 명령 뒤에 토큰을 붙여 실행하지 않는다.
노출했다면 Cloudflare에서 토큰을 회전한 뒤 connector를 다시 만든다.
다음 단계에서 이 긴 값만 복사해 화면에 표시되지 않는 입력으로 저장한다.
3. cloudflared 이미지와 실행 사용자를 먼저 확인하기
이 글에서는 실제 서버에서 검증한 cloudflare/cloudflared:2026.8.2 이미지를 고정해 사용한다. 2026년 9월 1일에는 더 새 태그가 보일 수 있지만, 이 글의 설정과 UID를 확인한 기준은 2026.8.2다.
Registered tunnel connection과 연결 정보가 반복해서 보이면 connector가 Cloudflare에 연결된 것이다. 로그 전체를 그대로 온라인에 올리면 계정·Tunnel·네트워크 식별 정보가 포함될 수 있으므로 필요한 줄만 남기고 가린다.
[Cloudflare 화면에서 확인]
Networking → Tunnels에서 방금 만든 Tunnel 상태가 Healthy인지 확인한다. Healthy는 Cloudflare와 connector가 연결됐다는 뜻이지, 아직 도메인과 WordPress가 정상이라는 뜻은 아니다.
8. Published application을 wordpress:80에 연결하기
Tunnel이 Healthy가 된 뒤 공개 호스트를 추가한다.
[Cloudflare 화면에서 설정]
Networking → Tunnels에서 만든 Tunnel을 선택한다.
Routes 탭을 연다.
Add route → Published application을 선택한다.
Hostname은 루트 도메인을 선택하고 Subdomain과 Path는 비워 둔다.
Service type은 HTTP를 선택한다.
Service URL에는 wordpress:80을 입력한다.
최종 표시가 http://wordpress:80인지 확인하고 저장한다.
여기서 localhost:80을 입력하면 안 된다. Cloudflared가 별도 컨테이너에서 실행되므로 그 안의 localhost는 WordPress가 아니라 cloudflared 컨테이너 자신이다. 두 컨테이너가 함께 연결된 edge 네트워크에서 Docker 서비스 이름 wordpress를 사용해야 한다.
VM의 사설 IP나 Mac 주소도 넣지 않는다. 컨테이너 이름을 쓰면 VM 주소가 바뀌어도 같은 Compose 네트워크 안에서 WordPress를 찾을 수 있다.
Cloudflare 전체 DNS 설정을 사용하는 도메인이라면 Published application을 저장할 때 <Tunnel UUID>.cfargotunnel.com을 향하는 DNS 레코드가 자동으로 만들어진다. 루트 도메인은 Cloudflare의 CNAME Flattening으로 처리된다.
An A, AAAA, or CNAME record already exists 같은 충돌 메시지가 나오면 같은 루트 호스트명의 기존 A·AAAA·CNAME 용도를 확인한다. 메일용 MX·TXT 레코드는 이 오류를 해결하겠다며 삭제하지 않는다.
저장 직후 도메인에 접속하면 앞에서 만든 임시 Access 로그인 화면이 먼저 나와야 한다. WordPress 초기 설치 화면이 인증 없이 바로 보이면 다음 단계로 가지 말고, 전체 사이트 Access 애플리케이션의 Domain과 Path부터 확인한다.
9. Access를 통과해 WordPress 초기 설치하기
기존 브라우저에는 Cloudflare 로그인 쿠키가 남아 있을 수 있으므로 새 시크릿 창을 사용한다.
[브라우저에서 확인]
새 시크릿 창에서 https://내도메인을 연다.
Cloudflare Access 로그인 화면이 나오는지 확인한다.
Allow 정책에 등록한 정확한 이메일을 입력한다.
받은 One-time PIN으로 로그인한다.
그 다음 WordPress 초기 설치 화면이 나오는지 확인한다.
OTP 메일은 정책에 허용된 이메일에만 발송된다. 허용되지 않은 주소를 입력해도 계정 존재 여부를 숨기기 위해 화면에는 “코드를 보냈다”고 표시될 수 있다.
Cloudflare 계정 비밀번호와 WordPress 비밀번호를 같게 만들지 않는다. 생성한 WordPress 비밀번호는 비밀번호 관리자에 저장하고, 비밀번호가 표시된 설치 화면은 캡처하지 않는다.
설치가 끝나면 Access를 통과한 같은 창에서 WordPress 관리자 로그인이 되는지 확인한다. 주소 표시줄이 https://이고, WordPress가 HTTP 주소로 되돌리지 않는지도 본다.
10. 전체 잠금에서 관리자 전용 잠금으로 전환하기
WordPress 설치가 끝났다고 임시 전체 사이트 Access부터 지우면 안 된다. 관리자 보호 경로를 먼저 만든 뒤 새 시크릿 창에서 확인하고, 필요한 공개 예외가 있는지 판단한 다음 마지막에 전체 잠금을 제거한다.
그림 2. 2026년 9월 1일 Mac에서 쓰기 없는 HEAD 요청으로 확인한 현재 상태다. 홈페이지는 바로 열리고 로그인·관리자·admin-ajax 경로는 Access 인증을 먼저 거친다. Access 주소와 식별 정보는 화면에서 제외했다.
이 결과에서 ADMIN_AJAX ACCESS_LOGIN은 오류가 아니다. 현재 공개 화면에서 이 경로에 의존하는 기능을 쓰지 않기 때문에 더 넓게 열지 않았다. 댓글, 문의 폼, 검색 필터 같은 공개 기능이 실제로 멈추고 그 요청이 admin-ajax.php로 가는 것이 확인될 때만 아래 예외를 추가한다.
관리자 경로 Access 만들기
[Cloudflare 화면에서 설정]
Zero Trust → Access controls → Applications로 이동한다.
새 Self-hosted and private 애플리케이션을 만든다.
이름을 WordPress 관리자 보호로 적는다.
같은 도메인에 아래 세 경로를 Public hostname으로 추가한다.
정책 Action은 Allow, Include는 소유자의 정확한 Emails로 설정한다.
One-time PIN 또는 사용 중인 IdP를 선택해 저장한다.
/wp-login.php
/wp-admin
/wp-admin/*
/wp-admin/*만 등록하면 부모 경로인 /wp-admin 자체는 포함되지 않는다. WordPress가 보통 /wp-admin/으로 리디렉션하더라도 두 경로를 모두 명시하는 편이 안전하다. 쿼리 문자열은 Access path 판정 대상이 아니므로 /wp-login.php?action=logout도 같은 로그인 경로 규칙으로 보호된다.
admin-ajax.php 공개 예외는 필요할 때만 만들기
일부 테마와 플러그인은 로그인하지 않은 방문자의 요청도 /wp-admin/admin-ajax.php로 보낸다. 넓은 /wp-admin/* 보호만 두면 이런 기능이 Access 로그인 화면으로 리디렉션될 수 있다. 반대로 그런 기능이 없다면 예외를 미리 만들 이유도 없다.
[Cloudflare 화면에서 설정]
새 시크릿 창에서 댓글, 문의 폼, 검색 필터처럼 로그인하지 않은 방문자가 쓰는 기능을 실행한다.
브라우저 개발자 도구의 Network에서 실패한 요청이 정확히 /wp-admin/admin-ajax.php인지 확인한다.
그 요청이 Cloudflare Access 로그인으로 리디렉션돼 기능이 멈춘 경우에만 이 경로를 대상으로 별도의 Self-hosted Access 애플리케이션을 만든다.
정책 Action은 Bypass, Include는 Everyone으로 설정한다.
경로가 정확히 /wp-admin/admin-ajax.php인지 다시 확인하고 저장한다.
같은 공개 기능을 새 시크릿 창에서 다시 실행해 정상 동작하는지 확인한다.
더 구체적인 경로가 넓은 /wp-admin/* 규칙보다 우선한다. Bypass는 해당 요청에 Access 인증과 Access 로그를 적용하지 않으므로 /wp-admin/* 전체에 사용하면 안 된다.
/wp-admin/admin-post.php는 공개 폼이나 플러그인이 실제로 요구할 때만 같은 방식의 정확한 경로 예외를 추가한다. 필요 여부를 확인하지 않고 미리 넓히지 않는다.
새 규칙을 먼저 시험하기
아직 임시 전체 사이트 Access는 남아 있으므로 공개 페이지까지 인증을 요구하는 것이 정상이다. 먼저 관리자 경로 세 개가 저장됐는지 확인한다. admin-ajax.php 예외는 앞 단계에서 필요성이 확인된 경우에만 함께 확인한다.
그 다음 임시 전체 사이트 애플리케이션을 제거하거나 비활성화해 공개 전환한다. 전환 직후 새 시크릿 창에서 다시 확인한다.
주소
기대 결과
/와 대표 글
Access 로그인 없이 공개
이미지와 /wp-json/
Access 로그인 없이 공개
/robots.txt, sitemap
Access 로그인 없이 공개
/wp-login.php
Cloudflare Access 로그인으로 이동
/wp-admin, /wp-admin/
Cloudflare Access 로그인으로 이동
/wp-admin/admin-ajax.php
기본은 Access 로그인으로 이동. 필요한 경우에만 정확한 경로 Bypass
Admin AJAX 예외를 만든 경우, 이 주소를 아무 동작 없이 직접 열면 WordPress가 0이나 400 응답을 줄 수 있다. 완료 기준은 무조건 HTTP 200이 아니라 Access 로그인으로 이동하지 않고 실제 공개 기능이 정상 동작하는지다. 예외를 만들지 않았다면 Access 로그인으로 이동하는 것이 현재 보호 범위와 일치한다.
사이트 전체 Access를 계속 남겨 두면 일반 독자뿐 아니라 검색엔진과 광고 crawler도 로그인 화면을 만나게 된다. 공개 사이트에서는 홈페이지와 글을 인증 없이 열고, 관리자 입구만 보호한다.
11. www 주소와 HTTPS 마무리하기
루트 도메인만 운영 주소로 쓸 경우 www 요청도 같은 Tunnel을 통과할 수 있는 Proxied DNS 또는 Published application이 먼저 있어야 한다.
[Cloudflare 화면에서 설정]
같은 Tunnel에 www Published application을 추가한다.
Service URL은 루트 도메인과 같은 http://wordpress:80으로 둔다.
Rules → Redirect Rules → Single Redirects로 이동한다.
Wildcard 패턴으로 다음 값을 입력한다.
Request URL: http*://www.example.com/*
Target URL: https://example.com/${2}
Status code: 301
Preserve query string: On
첫 번째 *는 http*의 s 여부를, 두 번째 *는 경로를 잡기 때문에 대상 경로에 ${2}를 사용한다. 실제 설정에서는 example.com을 자신의 도메인으로 바꾼다.
브라우저와 Cloudflare 사이는 HTTPS이고, Cloudflare와 cloudflared 사이는 Tunnel 연결로 보호된다. 같은 Docker edge 네트워크 안에서 cloudflared가 WordPress로 넘기는 주소는 http://wordpress:80이다. 이 내부 HTTP 주소를 보고 공유기 포트가 열린 것으로 오해하면 안 된다.
HSTS는 이번 단계에서 바로 켜지 않는다. HSTS를 활성화한 뒤 HTTPS나 Cloudflare 설정을 끄면 설정된 기간 동안 방문자가 사이트에 접속하지 못할 수 있다. 관리자 로그인, www 리디렉션, 공개 글, 이미지, 광고·분석 스크립트를 충분히 검증한 뒤 짧은 Max Age부터 별도로 적용한다.
12. Mac에서 공개·보호 경로를 한 번에 확인하기
로그인 쿠키의 영향을 받지 않는 명령행에서도 응답을 확인한다.
[Mac에서 실행]
read -rp "사이트 주소(예: https://example.com): " access_site_url
access_site_url="${access_site_url%/}"
access_check() {
local request_path=$1
local request_method=$2
local response_headers
local http_status
local access_result
if [[ "$request_method" == "POST" ]]; then
response_headers="$(
curl -sS \
--request POST \
--data '' \
--dump-header - \
--output /dev/null \
"${access_site_url}${request_path}"
)"
else
response_headers="$(
curl -sS \
--dump-header - \
--output /dev/null \
"${access_site_url}${request_path}"
)"
fi
http_status="$(
printf '%s\n' "$response_headers" |
awk '/^HTTP/{status=$2} END{print status}'
)"
if printf '%s\n' "$response_headers" |
grep -qi '^location: .*cloudflareaccess\.com'; then
access_result=ACCESS_LOGIN
else
access_result=NO_ACCESS_REDIRECT
fi
printf '%-32s status=%s result=%s\n' \
"$request_path" \
"$http_status" \
"$access_result"
}
access_check / GET
access_check /robots.txt GET
access_check /wp-login.php GET
access_check /wp-admin/ GET
access_check /wp-admin/admin-ajax.php POST
unset access_site_url
unset -f access_check
이 명령은 리디렉션 주소 전체를 출력하지 않고 ACCESS_LOGIN 또는 NO_ACCESS_REDIRECT로만 분류한다. 현재 확인에서는 공개 페이지가 NO_ACCESS_REDIRECT, /wp-login.php와 /wp-admin/, /wp-admin/admin-ajax.php가 ACCESS_LOGIN이었다. 공개 기능 때문에 정확한 Bypass를 만든 경우에만 마지막 결과가 NO_ACCESS_REDIRECT여야 하며, 실제 기능 동작도 함께 확인한다.
Ubuntu에서도 웹 포트가 계속 비공개인지 확인한다.
[SSH로 Ubuntu에서 실행]
PUBLISHED_PORTS="$(
for CONTAINER_ID in $(
sudo docker compose \
--env-file /opt/ttakdap/.env \
-f /opt/ttakdap/compose.yaml \
--profile tunnel \
ps -q db wordpress cloudflared
); do
sudo docker port "$CONTAINER_ID"
done
)"
if [[ -z "$PUBLISHED_PORTS" ]]; then
echo "호스트 공개 포트 없음"
else
printf '확인 필요한 포트 매핑:\n%s\n' "$PUBLISHED_PORTS"
fi
unset PUBLISHED_PORTS CONTAINER_ID
정상 결과는 여전히 호스트 공개 포트 없음이다. Tunnel은 Ubuntu에서 외부로 연결하므로 Docker ports:나 공유기 포트포워딩이 필요하지 않다.
그림 3. 2026년 9월 1일 관리자 표시를 제외하고 다시 캡처한 실제 공개 홈페이지. 일반 방문자는 별도의 Access 인증 없이 이 화면에 들어올 수 있다.
문제가 생겼을 때 확인할 순서
한 번에 DNS, Tunnel, Docker, Access를 모두 바꾸면 원인을 찾기 어려워진다. 앞 단계에서 확인한 사실은 유지하고 다음 연결만 본다.
그림 4. Tunnel이 연결되지 않은 문제, WordPress까지 가지 못하는 문제, Access 정책 문제를 서로 다른 구간으로 나눠 확인한다.
Tunnel이 Inactive 또는 Down이다
Cloudflare와 connector 사이 문제다. 아직 WordPress 설정을 바꾸지 않는다.
cloudflared 컨테이너가 반복 재시작하는지 확인한다.
토큰 파일 UID/GID가 65532:65532, mode가 400인지 확인한다.
Cloudflare에서 복사한 토큰이 현재 Tunnel의 토큰인지 확인한다.
토큰을 회전했다면 파일도 새 토큰으로 바꾸고 컨테이너를 다시 만든다.
Ubuntu에서 Cloudflare로 나가는 TCP·UDP 7844가 차단됐는지 확인한다.
두 컨테이너 결과에 같은 ttakdap_edge 네트워크가 보여야 한다. MariaDB는 ttakdap_db_internal에 있고 cloudflared와 네트워크를 공유하지 않는 것이 정상이다.
OTP 메일이 오지 않는다
먼저 Access Allow 정책의 이메일과 로그인 화면에 입력한 주소가 정확히 같은지 확인한다. 허용되지 않은 주소여도 화면에는 코드가 발송됐다고 보일 수 있다.
그 다음 One-time PIN identity provider 선택, 스팸함, 메일 보안 필터를 확인한다. 새 코드를 요청하면 이전 코드는 무효가 되고, 코드는 10분 후 만료된다.
공개 글까지 Access 로그인이 나온다
초기 설치용 전체 사이트 Access가 아직 남아 있는지 확인한다. 관리자 경로 규칙만 만든 것으로는 더 넓은 전체 사이트 규칙이 자동으로 없어지지 않는다.
새 시크릿 창으로 확인해야 한다. 기존 창은 Access 로그인 쿠키 때문에 공개·보호 범위를 잘못 판단할 수 있다.
관리자 화면은 되는데 공개 기능이 멈춘다
브라우저 개발자 도구나 플러그인 문서에서 요청 주소가 실제로 /wp-admin/admin-ajax.php인지 먼저 확인한다. 현재처럼 이 경로가 Access로 이동하고 공개 기능에 필요함이 확인됐다면, 정확한 hostname과 path의 별도 애플리케이션 및 Bypass 정책이 활성 상태인지 본다. 필요성이 확인되기 전에는 예외를 만들지 않고, 해결을 위해 /wp-admin/* 전체를 공개하지도 않는다.
HTTPS 리디렉션이 반복된다
2편의 .env에 SITE_URL=https://내도메인이 들어 있는지 확인한다. Cloudflare Always Use HTTPS를 사용한다면 Apache에 별도의 무조건 HTTP→HTTPS 규칙을 겹쳐 넣지 않는다.
[SSH로 Ubuntu에서 실행]
sudo sed -n 's/^SITE_URL=/SITE_URL=/p' /opt/ttakdap/.env
사이트 주소는 공개값이지만 도메인 철자가 맞는지 확인한다. DB 비밀번호와 Tunnel 토큰 파일은 출력하지 않는다.
문제가 생겼을 때 Tunnel만 되돌리는 방법
Cloudflare Published application을 제거하거나 비활성화해 새 외부 요청을 먼저 멈춘 뒤 connector만 내린다. WordPress와 MariaDB 볼륨은 건드리지 않는다.
1편에서 Parallels에 Ubuntu Server를 설치하고 Mac에서 SSH 키로 접속하는 데까지 마쳤다. 이번에는 Ubuntu 안에 Docker Engine과 Compose를 설치한 뒤 WordPress와 MariaDB를 각각의 컨테이너로 실행한다.
실제 서버를 만들 때는 반복 작업을 줄이려고 여러 작업을 .sh 파일로 묶었다. 내 서버에서는 편했지만 다른 사람이 그대로 따라 하기는 어려웠고, 중간에 멈추면 무엇이 끝났는지도 알기 힘들었다. 그래서 이 글에서는 스크립트 대신 Docker 설치, 폴더 생성, 설정 파일 작성, 컨테이너 실행을 하나씩 펼쳤다. SSH 사용자명·주소와 도메인은 실행할 때 직접 묻고, 나머지는 별도 파일을 받지 않아도 본문의 완성된 코드 블록을 복사할 수 있게 했다.
이번 글을 끝내면 다음 상태가 된다.
Ubuntu 24.04에 Docker Engine과 Compose 플러그인이 설치된다.
WordPress와 MariaDB 설정 파일이 /opt/ttakdap에 만들어진다.
DB 비밀번호는 Compose 파일이나 셸 기록이 아닌 권한이 제한된 파일에 저장된다.
MariaDB는 Docker 내부 전용 네트워크에만 연결된다.
WordPress와 DB 데이터는 이름 있는 Docker 볼륨에 보관된다.
WordPress와 MariaDB 컨테이너가 모두 정상 상태인지 직접 확인한다.
SSH는 키 인증만 허용하고, UFW는 Mac에서 오는 관리 접속만 받는다.
아직 WordPress 포트를 공개하거나 Cloudflare Tunnel을 연결하지 않는다.
이미 다른 Docker 서비스를 운영 중인 Ubuntu라면 이 글을 그대로 진행하지 않는다. 기존 패키지, 네트워크 이름, 포트, 볼륨을 먼저 확인해야 한다.
명령을 입력하는 화면부터 구분하기
이번 글에서는 코드 블록 바로 위에 실행 장소를 표시한다.
글의 표시
명령을 입력하는 화면
명령이 적용되는 곳
Mac에서 실행
macOS 터미널
Mac
SSH로 Ubuntu에서 실행
Mac 터미널로 Ubuntu에 접속한 화면
Ubuntu VM
Ubuntu VM 콘솔에서 실행
Parallels의 Ubuntu 창
Ubuntu VM
SSH 화면은 Mac 터미널 안에 있지만, 접속한 뒤 입력한 명령은 Ubuntu에서 실행된다. 헷갈리면 다음 두 줄부터 입력한다.
uname -s
pwd
Darwin과 /Users/...가 보이면 Mac이다. Linux와 /home/...가 보이면 Ubuntu다. 이 글에서 SSH로 Ubuntu에서 실행이라고 표시한 명령은 첫 줄이 Linux인 화면에서만 입력한다.
sudo가 비밀번호를 물을 때 입력한 글자나 별표가 화면에 나타나지 않는 것도 정상이다. Ubuntu 계정 비밀번호를 한 번 입력하고 Enter를 누른다. 비밀번호를 명령 뒤에 붙이거나 채팅창에 보내면 안 된다.
0. 설치 전 복구 지점 만들기
Docker 저장소, 방화벽, SSH 설정을 바꾸기 전에 1편에서 만든 키 접속이 실제로 되는지 새 Mac 터미널에서 확인한다.
[Mac에서 실행]
read -rp "Ubuntu 사용자명: " ADMIN_USER
read -rp "Ubuntu VM 주소: " VM_IP
if [[ -z "$ADMIN_USER" || -z "$VM_IP" ]]; then
echo "사용자명과 VM 주소를 모두 입력해야 합니다."
else
ssh -i "$HOME/.ssh/ttakdap_vm" \
-o IdentitiesOnly=yes \
-o BatchMode=yes \
"$ADMIN_USER@$VM_IP" \
'uname -s; id -un'
fi
1편에서 만든 Ubuntu 사용자명과 VM 주소를 질문에 입력한다. 정상이라면 Linux와 Ubuntu 사용자명이 나온다. BatchMode=yes는 계정 비밀번호로 넘어가지 않으므로, 이 명령이 성공해야 공개키 접속이 제대로 준비된 것이다.
그다음 Ubuntu를 정상 종료하고 Parallels 스냅샷을 하나 만든다. VM을 선택한 뒤 Mac 메뉴 막대에서 Actions → Take Snapshot을 누르고, 이름은 Ubuntu SSH 준비 완료처럼 복구 시점을 알 수 있게 정했다. 메뉴 이름은 Parallels 공식 스냅샷 안내에서도 확인할 수 있다. VM을 다시 켠 뒤에는 Parallels 콘솔을 닫지 않는다. 뒤에서 방화벽이나 SSH 설정이 잘못돼도 콘솔로 돌아갈 수 있기 때문이다.
1. Ubuntu 버전과 기존 Docker 확인하기
Ubuntu에 SSH로 접속한다.
[Mac에서 실행]
read -rp "Ubuntu 사용자명: " ADMIN_USER
read -rp "Ubuntu VM 주소: " VM_IP
if [[ -z "$ADMIN_USER" || -z "$VM_IP" ]]; then
echo "사용자명과 VM 주소를 모두 입력해야 합니다."
else
ssh -i "$HOME/.ssh/ttakdap_vm" \
-o IdentitiesOnly=yes \
"$ADMIN_USER@$VM_IP"
fi
예전 글에 나오는 docker-compose 패키지는 따로 설치하지 않는다. 지금 사용하는 명령은 하이픈이 없는 docker compose다.
설치와 서비스 상태를 확인한다.
sudo systemctl enable --now docker
sudo systemctl is-active docker
sudo docker version --format 'Docker Engine {{.Server.Version}}'
sudo docker compose version
sudo docker run --rm hello-world
정상이면 active, Docker Engine 버전, Docker Compose version ..., Hello from Docker!가 차례로 보인다. 버전 번호는 설치 시점에 따라 달라질 수 있다. 마지막 명령은 작은 시험용 이미지를 내려받아 컨테이너를 한 번 실행한 뒤 컨테이너만 자동 삭제한다.
이 글에서는 계속 sudo docker ...를 사용한다. 사용자를 docker 그룹에 넣으면 sudo 없이 실행할 수 있지만, 그 그룹은 사실상 서버 전체를 제어할 수 있는 큰 권한을 준다.
3. WordPress 운영 폴더 만들기
컨테이너 설정과 비밀번호 파일을 한곳에서 관리하기 위해 /opt/ttakdap을 만든다. 일반 사용자 실수로 파일이 바뀌지 않도록 소유자는 root로 둔다.
sudo test -s /opt/ttakdap/secrets/db_password.txt &&
echo "WordPress DB 비밀번호 파일 준비 완료"
sudo test -s /opt/ttakdap/secrets/db_root_password.txt &&
echo "MariaDB root 비밀번호 파일 준비 완료"
sudo stat -c '%U %G %a %n' \
/opt/ttakdap/secrets/db_password.txt \
/opt/ttakdap/secrets/db_root_password.txt
중요한 주의점이 하나 있다. MariaDB를 한 번이라도 시작한 뒤에는 이 두 파일을 삭제하거나 다시 만들면 안 된다. DB 볼륨에 저장된 기존 비밀번호와 새 파일의 값이 달라져 인증 오류가 날 수 있다. 오류가 났다고 secret 파일부터 지우지 않는다.
Compose secret은 값을 암호화해 주는 비밀 금고가 아니다. 여기서는 호스트의 권한 제한 파일을 필요한 컨테이너에만 읽기 전용으로 전달한다. 비밀번호를 환경변수와 로그에 노출할 가능성을 줄이는 방식이다.
6. MariaDB와 PHP 설정 파일 만들기
별도 다운로드 파일 없이 아래 완성본을 그대로 저장한다. 각 블록은 첫 줄부터 마지막 EOF까지 한 번에 복사한다.
여기의 healthcheck.sh는 독자가 따로 만들거나 내려받는 파일이 아니다. MariaDB 공식 이미지 안에 기본으로 들어 있는 상태 확인 도구다. --connect는 DB 접속 가능 여부를, --innodb_initialized는 InnoDB 초기화가 끝났는지를 확인한다. 마지막에 MariaDB 준비 완료가 나오면 두 검사가 모두 통과한 것이다.
80/tcp와 3306/tcp만 보이는 것은 정상이다. 실제 호스트 매핑이 하나도 없는지는 docker port로 확인한다.
PUBLISHED_PORTS="$(
for CONTAINER_ID in $(
sudo docker compose \
--env-file /opt/ttakdap/.env \
-f /opt/ttakdap/compose.yaml \
ps -q db wordpress
); do
sudo docker port "$CONTAINER_ID"
done
)"
if [[ -z "$PUBLISHED_PORTS" ]]; then
echo "호스트 공개 포트 없음"
else
echo "확인 필요한 포트 매핑:"
printf '%s\n' "$PUBLISHED_PORTS"
fi
정상 결과는 호스트 공개 포트 없음이다. Docker 공식 문서는 컨테이너 포트를 호스트에 게시하면 UFW 규칙을 우회할 수 있다고 경고한다. 그래서 이번 Compose에는 80:80이나 3306:3306 같은 ports 설정을 넣지 않았다.
특히 다음 명령은 볼륨까지 삭제하므로 실행하면 안 된다.
docker compose down -v
docker system prune --volumes도 사용하지 않는다. 어떤 볼륨이 지워질지 정확히 확인하지 않은 정리 명령은 WordPress 파일이나 DB 데이터를 없앨 수 있다.
처음부터 다시 하고 싶더라도 운영 데이터를 지우는 명령부터 실행하지 말고, 설치 전 만든 Parallels 스냅샷으로 돌아가는 편이 범위가 분명하다.
11. UFW에서 Mac의 SSH만 허용하기
이제 컨테이너는 내부에서 동작하지만 SSH 관리 포트는 정리해야 한다. 이 절을 시작하기 전에 Parallels의 Ubuntu 콘솔과 현재 SSH 창을 모두 열어 둔다.
현재 SSH 접속이 어느 주소에서 들어왔는지 확인하고, IPv4 형식과 사용자 확인이 모두 통과했을 때만 UFW를 켠다. 이 블록은 Parallels 콘솔이 아니라 현재 SSH 접속 화면에서 실행한다.
[SSH로 Ubuntu에서 실행]
ADMIN_IP="${SSH_CONNECTION%% *}"
printf '현재 SSH 접속 출발지: %s\n' "$ADMIN_IP"
if printf '%s\n' "$ADMIN_IP" |
awk -F. '
NF != 4 { bad = 1 }
{
for (i = 1; i <= 4; i++) {
if ($i !~ /^[0-9]+$/ || $i > 255) bad = 1
}
}
END { exit bad }
'
then
read -rp "이 주소에서 오는 SSH만 허용할까요? [y/N] " UFW_CONFIRM
if [[ "$UFW_CONFIRM" =~ ^[Yy]$ ]]; then
sudo apt install -y ufw &&
sudo ufw allow from "${ADMIN_IP}/32" \
to any port 22 proto tcp \
comment 'Mac admin SSH' &&
sudo ufw default deny incoming &&
sudo ufw default allow outgoing &&
sudo ufw --dry-run enable &&
sudo ufw --force enable &&
sudo ufw status verbose
else
echo "UFW를 켜지 않았습니다."
fi
else
echo "IPv4 주소를 확인하지 못해 UFW를 켜지 않았습니다."
fi
Parallels Shared Network에서 Mac이 사용하는 실제 IPv4 주소가 나와야 한다. 값이 비어 있거나 예상하지 못한 주소면 UFW는 켜지지 않는다. 허용 규칙 중 하나라도 실패하면 && 뒤의 활성화 명령도 실행되지 않는다.
Linux와 Ubuntu 사용자명이 다시 나오면 키 전용 로그인이 적용된 것이다. 새 접속이 성공하기 전에는 기존 SSH 창이나 Parallels 콘솔을 닫지 않는다.
13. 브라우저에서 바로 안 열려도 정상이다
이번 단계에서는 브라우저에 다음 주소를 입력해도 WordPress 설치 화면이 나오지 않는다.
http://<VM_IP>
오류가 아니라 의도한 구성이다. compose.yaml에 ports 항목이 없기 때문에 WordPress 컨테이너의 80번 포트가 Ubuntu 호스트에 연결되지 않았다.
2편의 완료 기준은 브라우저 화면이 아니라 다음 세 가지다.
db와 wordpress가 모두 healthy다.
WordPress에서 실제 DB 로그인과 내부 80번 포트 검사가 통과한다.
docker port 검사 결과가 호스트 공개 포트 없음이다.
3편에서는 현재 구성을 유지한 채 Cloudflare Tunnel 컨테이너를 edge 네트워크에 추가한다. 공유기나 VM에 웹 포트를 열지 않고, 외부에서 들어온 HTTPS 요청을 Tunnel이 wordpress:80으로 전달하게 한다. WordPress 초기 설정 화면도 그때 연다.
문제가 생겼을 때 확인할 순서
docker: command not found가 나온다
Docker 패키지 설치가 끝나지 않은 것이다. 아래 두 명령부터 확인한다.
apt-cache policy docker-ce
sudo systemctl status docker --no-pager
docker-ce 후보 버전이 보이지 않으면 앞에서 만든 Docker 저장소 설정 파일과 apt update 출력을 확인한다.
앞으로 나오는 <VM_IP>와 <ADMIN_USER>는 예시가 아니라 바꿔 넣을 자리다. 실제 명령에서는 자신이 확인한 값으로 바꾸고 꺾쇠괄호도 제거해야 한다.
준비물
Apple Silicon 맥미니
현재 버전의 Parallels Desktop
Ubuntu Server 24.04.4 LTS ARM64 ISO
안정적인 인터넷 연결
Ubuntu 계정에 사용할 사용자명과 긴 비밀번호
VM 파일을 보관할 Mac의 로컬 저장 공간
이번에 만든 VM의 설정은 다음과 같다.
항목
이번 구성값
CPU
4 vCPU
메모리
8GB
가상 디스크
64GB 확장형
네트워크
Shared Network(NAT)
운영체제
Ubuntu Server 24.04 LTS ARM64
이 숫자는 WordPress의 공식 최소 사양이나 모든 맥미니에 맞는 정답이 아니다. 이번 구축에 사용한 값이다. Ubuntu가 안내하는 ISO 설치 권장 최소치는 이보다 낮지만, Docker·WordPress·DB와 백업 파일까지 둘 여유를 고려해 넉넉하게 잡았다. Parallels가 표시하는 권장 범위와 Mac에서 동시에 실행할 작업도 함께 봐야 한다.
1. Mac이 Apple Silicon인지 확인하기
먼저 Mac 터미널을 연다.
[Mac에서 실행]
uname -m
Apple Silicon Mac이라면 다음과 같이 나온다.
arm64
x86_64가 나오면 Intel Mac이다. 이 글에서 사용하는 ARM64 ISO를 그대로 선택하면 안 된다.
2. Ubuntu Server ARM64 ISO 받기
Apple Silicon의 Parallels에서는 ARM용 게스트 운영체제를 사용해야 한다. Ubuntu 공식 릴리스 페이지에서 다음 파일을 받는다.
Shared Network에서는 VM이 Mac의 연결을 공유해 인터넷으로 나갈 수 있다. 반대로 외부 기기가 VM으로 직접 들어오게 하려면 별도 포트포워딩이 필요하다.
이번 서버는 뒤에서 Cloudflare Tunnel을 사용하므로 다음 설정을 만들지 않는다.
공유기 80·443 포트포워딩
Parallels VM 포트포워딩
브리지 네트워크
공인 고정 IP도 필요하지 않다. Tunnel 연결은 Ubuntu 쪽에서 먼저 외부로 나가기 때문이다.
Mac과 VM의 공유 기능
초기 작업이 끝난 뒤에는 다음 기능을 끈다.
Mac 공유 폴더와 공유 프로필
Mac 볼륨 자동 매핑
클립보드 동기화
공유 기능을 켜면 VM에서 Mac 파일이나 클립보드 내용에 접근할 수 있다. 공개 서버용 VM에 꼭 필요한 기능이 아니므로 공격면을 줄이기 위한 선택이다. 이는 Parallels의 필수 설정이 아니라 이번 서버의 운영 방침이다.
그림 2. 운영 중인 WordPress VM에서 값을 바꾸지 않고 현재 설정을 확인했다. 프로세서 4개, 메모리 8GB, 가상 디스크 64GB, 공유 네트워크(NAT)를 사용 중이다.
5. Ubuntu Server 설치 화면 따라가기
VM을 시작하면 Ubuntu Server 설치 프로그램이 열린다. 화면 문구는 ISO 개정판에 따라 조금 달라질 수 있지만 공식 설치 흐름은 다음과 같다.
Language → Keyboard → Source → Network → Proxy → Mirror
→ Storage → Profile setup → SSH Setup → Featured Server Snaps
→ 설치 → Reboot
언어와 키보드
원하는 언어와 실제 키보드에 맞는 배열을 선택한다. 오류 메시지를 검색하기 쉽게 설치 화면은 영문으로 진행해도 된다. 키보드 배열이 틀리면 이후 비밀번호 입력에서 특수문자가 다르게 들어갈 수 있다.
설치 프로그램 업데이트
새 설치 프로그램을 받을 수 있다는 안내가 나오면 네트워크 상태를 확인한 뒤 업데이트할 수 있다. 화면이 다시 시작되더라도 ISO 자체를 다시 받는 과정은 아니다.
설치 유형
초보자는 일반 Ubuntu Server를 선택한다.
Ubuntu Server (minimized)는 그래픽 화면만 없는 정도가 아니라, 설명서·번역·편집기처럼 사람이 직접 관리할 때 유용한 도구를 더 줄인 설치 유형이다. Server ISO 자체가 이미 GUI 없는 서버판이므로 이 글에서는 일반 Ubuntu Server를 사용한다.
네트워크
Shared Network의 DHCP로 주소와 기본 경로가 잡혀 있으면 그대로 진행한다. 이 단계에서 외부 공개용 고정 IP를 만들 필요는 없다.
프록시와 미러
회사나 학교에서 별도의 프록시 주소를 받은 경우가 아니라면 Proxy 입력칸은 비워 둔다. Mirror 검사가 성공하면 기본값을 유지한다.
디스크 설정 — 가장 주의할 단계
새 VM의 빈 가상 디스크 하나만 보이고 크기가 앞에서 설정한 값과 맞는지 확인한 뒤 Use an entire disk를 선택한다.
경고 설치 대상은 반드시 Parallels가 만든 약 64GB 가상 디스크여야 한다. 디스크가 여러 개 보이거나 용량이 예상과 다르면 확정하지 말고 뒤로 돌아간다. VM에 외장 디스크를 연결해 둔 상태라면 특히 주의한다.
수동 파티션은 이번 구성에서 사용하지 않았다. 기본 LVM 선택은 그대로 둘 수 있다. 디스크 암호화는 보안에 도움이 되지만, LUKS 암호를 켜면 VM을 재부팅할 때마다 콘솔에서 암호를 넣어야 하므로 무인 복구 방식과 충돌할 수 있다. 선택하려면 이 운영상 차이를 먼저 이해해야 한다.
사용자 계정 만들기
Profile setup 화면에서 이름, 서버 이름, 사용자명, 비밀번호를 만든다.
서버 이름: 예를 들어 ttakdap-ubuntu
사용자명: <ADMIN_USER> 대신 자신이 정한 이름
비밀번호: 다른 사이트에서 쓰지 않는 긴 비밀번호
이때 만든 첫 사용자는 sudo 명령을 사용할 수 있는 관리자다. 나중에 SSH 키를 등록하더라도 sudo 작업에는 이 암호가 필요할 수 있으므로 비밀번호 관리자에 보관한다.
사용자명과 서버 이름은 터미널 프롬프트와 화면 캡처에 계속 나타난다. 블로그에 실제 화면을 올릴 때는 가리는 편이 좋다.
OpenSSH Server 선택
SSH Setup 화면에서 Install OpenSSH server를 선택한다.
이 항목을 빼면 설치 후 Mac 터미널에서 Ubuntu에 바로 접속할 수 없다. GitHub나 Launchpad의 SSH 키를 가져오는 기능은 이번 절차에서 사용하지 않고, 설치가 끝난 뒤 Mac에서 전용 키를 직접 만든다.
Featured Server Snaps
이번 구성에 필요한 항목은 없다. 아무것도 선택하지 않고 진행한다. Docker는 2편에서 Docker 공식 저장소를 통해 설치한다.
설치가 끝나면 Reboot Now를 선택한다. 설치 미디어를 제거하라는 안내가 나오면 Parallels의 CD/DVD 설정에서 ISO를 분리하고 Enter를 누른다. Parallels가 자동으로 분리했다면 별도로 조작할 필요가 없다.
재부팅 뒤에도 설치 화면으로 돌아오면 VM을 끄고 ISO 연결 여부부터 확인한다.
6. Ubuntu에 처음 로그인하기
재부팅이 끝나면 Ubuntu 로그인 화면이 나온다. 설치 중 만든 사용자명과 비밀번호를 입력한다.
비밀번호가 아무것도 표시되지 않는 것은 정상이다
Ubuntu 로그인, sudo, ssh에서 비밀번호를 입력하면 글자·점·별표가 모두 보이지 않을 수 있다. 커서도 그대로인 것처럼 보이지만 입력은 되고 있다. 비밀번호를 누른 뒤 Enter를 입력한다.
별도의 비밀번호 팝업이 뜨는 것도 아니다. 아래처럼 터미널 줄에 요청이 보인다.
[sudo] password for <ADMIN_USER>:
정상 여부는 로그인 뒤 다음 명령으로 확인할 수 있다.
[Ubuntu VM에서 실행]
sudo id -u
비밀번호를 입력한 뒤 아래 값이 나오면 관리자 권한이 정상이다.
0
직전에 sudo 인증을 했다면 일정 시간 다시 묻지 않을 수도 있다. Codex 실행 화면에서 대화형 입력이 필요한 명령이 멈춘 것처럼 보일 때는 비밀번호를 채팅에 붙여 넣지 말고 Parallels의 Ubuntu 콘솔에서 실행한다.
7. 설치 결과 확인하기
아래 명령을 한꺼번에 복사하지 말고 하나씩 실행하면서 결과를 본다.
Ubuntu 버전과 CPU 구조
[Ubuntu VM에서 실행]
cat /etc/os-release
uname -m
확인할 값은 다음과 같다.
VERSION_ID="24.04"
aarch64
ISO 파일명은 arm64, Linux 명령 결과는 aarch64라고 나오지만 여기서는 같은 CPU 계열을 뜻한다.
VM 자원과 가상 디스크
[Ubuntu VM에서 실행]
nproc
free -h
lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS
이번 구성이라면 CPU 개수는 4, 메모리는 약 8GB, 디스크는 약 64GB로 보여야 한다. 파일시스템과 단위 차이 때문에 화면의 숫자가 설정값과 정확히 같지 않을 수 있다.
네트워크와 SSH
[Ubuntu VM에서 실행]
ip route
hostname -I
systemctl is-active ssh
정상 기준은 다음과 같다.
ip route에 default via로 시작하는 기본 경로가 있다.
hostname -I에 VM의 내부 IPv4 주소가 나온다.
SSH 서비스 결과가 active다.
여기서 확인한 IPv4 주소를 뒤의 <VM_IP> 대신 사용한다. 예제 IP를 추측해서 넣지 않는다.
그림 3. Mac에서 현재 VM을 읽기 전용으로 확인한 결과다. Ubuntu 24.04.4 LTS, aarch64, 4 vCPU, 7.7GiB 메모리, 64GB 가상 디스크와 SSH active 상태를 확인했으며 사용자명·호스트명·내부 주소는 표시하지 않았다.
8. Mac에서 Ubuntu로 처음 SSH 접속하기
Parallels의 Ubuntu 콘솔은 그대로 열어 둔다. SSH 설정이 잘못됐을 때 돌아올 수 있는 복구 화면이기 때문이다.
새 Mac 터미널을 열고 아래 두 값을 자신이 만든 사용자명과 방금 확인한 VM 주소로 바꾼다. 꺾쇠괄호는 넣지 않는다.
[Mac에서 실행]
VM_IP="<VM_IP>"
ADMIN_USER="<ADMIN_USER>"
예를 들어 VM 주소가 192.0.2.10, 사용자명이 server-admin이라면 변수 오른쪽에 그 값만 넣는 식이다. 이 예시 주소는 문서용이므로 그대로 사용하지 않는다.
접속한다.
ssh "$ADMIN_USER@$VM_IP"
처음 접속하면 서버 지문을 신뢰할지 묻는다. 주소가 방금 Ubuntu에서 확인한 VM 주소인지 먼저 본다.
더 엄격하게 확인하려면 Ubuntu 콘솔에서 아래 명령을 실행해 표시되는 SHA-256 지문과 Mac의 SSH 화면을 비교한다.
사용 중인 macOS에서 --apple-use-keychain 옵션을 지원하지 않는다는 메시지가 나오면 ssh-add "$HOME/.ssh/ttakdap_vm"로 현재 로그인 세션에만 등록할 수 있다. 자동 운영에 사용할 키는 편의와 보안, Mac 계정과 디스크의 보호 상태를 함께 고려해 관리한다.