[카테고리:] 디지털

스마트폰·컴퓨터·웹서비스에서 막힌 문제를 단계별로 풉니다.

  • 맥미니를 24시간 홈서버로 써도 될까? 워드프레스 서버를 직접 구축하며 확인한 것들

    맥미니를 24시간 홈서버로 써도 될까? 워드프레스 서버를 직접 구축하며 확인한 것들

    워드프레스 하나 운영하자고 맥미니 안에 Ubuntu 가상머신까지 만드는 건 솔직히 가장 쉬운 길은 아니다. 글만 빨리 쓰고 싶다면 관리형 호스팅을 신청하는 편이 훨씬 간단하다.

    이미 쓰던 맥미니가 있어 새 장비 대신 그 안에 Ubuntu VM을 하나 두었다. 조건은 두 가지였다. 기존 macOS 작업과 WordPress 서버를 섞지 않을 것, 공유기 포트는 열지 않을 것. 그렇게 올린 ttakdap.com은 WordPress와 MariaDB를 VM 안에서 돌리고, 외부 연결은 Cloudflare Tunnel이 맡고 있다.

    WordPress 설치보다 시간을 더 쓴 건 공개 범위를 정하고, 관리자 입구를 따로 잠그고, 고장 났을 때 되살릴 길을 만드는 일이었다.

    현재 판정
    맥미니로 24시간 운영을 시작할 준비는 갖췄다. 하지만 장기간의 안정성과 정전 후 완전한 무인 복구까지 입증한 것은 아니다.

    공유기 포트를 열지 않은 맥미니 WordPress 홈서버 구성도

    그림 1. 방문자는 Cloudflare Tunnel을 거쳐 WordPress에 접속하고, MariaDB는 Docker 내부 네트워크에만 둔 실제 구성. 백업은 VM 안의 복구 세트와 암호화된 R2 사본으로 나눴다.

    직접 따라 하는 설치 안내

    이 글은 구성 이유와 운영하면서 확인한 장단점을 정리한 글이다. 처음부터 같은 서버를 만들고 싶다면 아래 설치 시리즈를 순서대로 보면 된다. 각 편은 앞 단계의 성공 여부를 확인한 뒤 다음 단계로 넘어가도록 나눴다.

    1. Parallels에 Ubuntu Server VM 만들기 — ISO 선택부터 SSH 키 접속까지
    2. Ubuntu에 Docker로 WordPress·MariaDB 설치하기 — 외부 공개 전 내부 구동까지
    3. Cloudflare Tunnel로 공개하고 관리자 경로 보호하기
    4. WP-Cron을 systemd로 바꾸고 Cloudflare 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 연결 → 초기 설정 동안 임시 전체 보호 → 공개 페이지 개방·관리자 경로만 보호
    WordPress 홈서버를 안쪽부터 단계적으로 공개한 순서

    그림 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이 시작되는지 아직 검증하지 않았다.

    그래서 지금 “정전 뒤에도 사람 손 없이 자동 복구된다”고 쓰면 과장이다. 실제로 남은 시험은 다음과 같다.

    1. 전원을 끊었다가 넣었을 때 맥미니가 다시 켜지는가.
    2. 사용자 로그인 없이 Parallels와 Ubuntu VM이 시작되는가.
    3. Docker와 세 컨테이너가 올바른 순서로 정상화되는가.
    4. 놓친 Cron과 백업 작업이 처리되는가.
    5. 외부에서 사이트가 다시 열릴 때까지 몇 분이 걸리는가.

    수개월간의 가동률, 실제 소비전력, 온도와 팬 소음, 동시 접속 성능도 아직 측정하지 않았다. 이런 수치를 다른 사람의 맥미니 사용기에서 가져와 채우기보다는, 스마트 플러그와 모니터링 기록을 쌓은 뒤 후속 글에 추가할 생각이다.

    지금 내린 결론

    이미 쓰던 맥미니가 있고 서버 운영 자체를 배워 보고 싶다면 해볼 만했다. 반대로 글 쓰는 데만 집중하고 싶거나, 장애 뒤 정해진 시간 안에 복구돼야 한다면 관리형 호스팅이 편하다. 이 구성에서는 문제가 생길 때 macOS부터 Cloudflare까지 직접 확인해야 한다.

    지금까지 끝낸 것은 사이트 공개, 관리자 경로 보호, 로컬·R2 백업 파일 검증까지다. 정전 뒤 무인 기동과 빈 VM 전체 복구는 아직 남았다. 두 시험을 마친 뒤에야 이 서버를 24시간 운영용이라고 마음 놓고 부를 수 있을 것 같다.

    관리자 표시 없이 캡처한 딱답 공개 홈페이지

    그림 4. 2026년 9월 1일 관리자 표시를 제외하고 다시 캡처한 딱답 공개 홈페이지. 현재 맥미니의 Ubuntu VM에서 서비스 중이다.

  • WordPress WP-Cron을 systemd로 바꾸고 Cloudflare R2에 백업하기: 복구 파일 검증까지

    WordPress WP-Cron을 systemd로 바꾸고 Cloudflare R2에 백업하기: 복구 파일 검증까지

    3편까지 끝내자 사이트는 밖에서 열렸다. 하지만 사람이 접속하지 않는 시간에도 예약 작업이 제때 실행되는지, VM이 사라졌을 때 백업을 다시 내려받을 수 있는지는 별개의 문제였다.

    이번에는 사이트를 더 공개하는 작업이 아니다. 사람이 보고 있지 않을 때도 WordPress의 예약 작업을 확인하게 만들고, 고장 뒤에 사용할 복구 재료를 남기는 작업이다. R2에 업로드 완료가 보였다는 이유만으로 끝내지 않고, 같은 세트를 다시 내려받아 체크섬과 압축 파일을 읽을 수 있는 데까지 확인했다.

    systemd 타이머와 암호화된 Cloudflare 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 메뉴는 나중에 달라질 수 있다.

    시작 전에 확인할 조건

    이 글은 3편을 끝낸 상태에서 이어진다.

    • /opt/ttakdap에 3편의 .env, compose.yaml, config, secrets가 있다.
    • db, wordpress, cloudflared가 정상 실행 중이다.
    • Mac에서 Ubuntu로 SSH 키 접속이 된다.
    • Ubuntu VM의 시간대가 Asia/Seoul이고 시간이 동기화돼 있다.
    • VM 디스크에 첫 전체 WordPress 파일 압축본을 둘 여유가 있다.
    • Cloudflare 계정에서 R2를 사용할 수 있다.
    • 긴 무작위 복구 암호를 VM 밖 비밀번호 관리자에 보관할 준비가 됐다.

    백업 중에는 플러그인·테마 설치, 대규모 미디어 업로드, DB 구조 변경을 같이 하지 않는다. DB와 파일을 차례로 복사하기 때문에 작업 중 사이트 내용이 크게 바뀌면 두 사본의 시점이 어긋날 수 있다.

    중요한 업데이트 직전에는 글 작성과 업로드를 잠시 멈추고 수동 백업을 한 번 만드는 편이 안전하다.

    명령을 입력하는 화면 확인하기

    글의 표시명령을 입력하는 화면하는 일
    Mac에서 실행macOS 터미널Ubuntu SSH 접속과 외부 확인
    SSH로 Ubuntu에서 실행Mac 터미널에서 Ubuntu에 접속한 화면Docker, systemd, 백업, rclone
    Cloudflare 화면에서 설정웹브라우저의 Cloudflare 대시보드R2 버킷과 전용 API 자격증명

    이 글의 설치 명령은 대부분 SSH로 들어간 Ubuntu 화면에서 실행한다. 위치가 헷갈리면 다음 두 줄을 먼저 입력한다.

    uname -s
    pwd

    Linux가 보여야 한다. R2 Secret Access Key와 복구 암호를 입력할 때는 화면에 글자나 별표가 나타나지 않는다. 정상 동작이며, 값을 채팅이나 명령 끝에 붙이면 안 된다.

    0. 컨테이너·시간·디스크 상태부터 확인하기

    자동화를 만들기 전에 현재 사이트가 정상인지 확인한다. 이미 고장 난 WordPress 위에 타이머를 올리면 실패 원인을 나누기 어렵다.

    [SSH로 Ubuntu에서 실행]

    timedatectl
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      ps
    
    df -h /
    sudo docker system df
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      exec -T wordpress \
      du -sh /var/www/html
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      exec -T db \
      du -sh /var/lib/mysql

    확인할 내용은 다음과 같다.

    • Time zone이 Asia/Seoul이다.
    • System clock synchronized가 yes다.
    • db와 wordpress는 healthy, cloudflared는 계속 실행 중이다.
    • 루트 디스크가 이미 80% 이상 찬 상태가 아니다.
    • 남은 공간이 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 파일을 직접 실행한다.

    Ubuntu systemd
      → Docker의 wordpress 컨테이너
      → www-data 사용자
      → php /var/www/html/wp-cron.php

    5분 타이머는 예약 작업을 초 단위 정시에 실행한다는 뜻이 아니다. 다음 호출에서 마감된 작업을 처리하므로 최대 약 5분과 systemd의 짧은 정확도 여유만큼 늦을 수 있다.

    2. 자동화 전에 WP-Cron을 한 번 직접 실행하기

    먼저 컨테이너 안에서 같은 명령이 성공하는지 본다. 이 단계가 실패하면 timer 파일을 만들지 않는다.

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      exec -T -u www-data wordpress \
      php /var/www/html/wp-cron.php &&
    echo "WP-Cron 직접 실행 성공"

    실행할 예약 작업이 없으면 PHP 출력이 전혀 없을 수 있다. 마지막의 WP-Cron 직접 실행 성공과 종료코드가 완료 기준이다.

    service "wordpress" is not running, DB 연결 오류, PHP 오류가 보이면 여기서 멈춘다. timer는 실패한 명령을 정상으로 바꿔 주지 않는다.

    3. WP-Cron 실행 스크립트와 systemd 서비스·타이머 만들기

    먼저 systemd가 실행할 작은 스크립트를 만든다. 이 파일이 하는 일은 /opt/ttakdap의 Compose를 읽고, WordPress 컨테이너에서 www-data 권한으로 wp-cron.php를 실행하는 것뿐이다.

    [SSH로 Ubuntu에서 실행]

    sudo install -d -m 0755 -o root -g root \
      /opt/ttakdap/scripts
    
    sudo tee /opt/ttakdap/scripts/wp-cron.sh >/dev/null <<'EOF'
    #!/usr/bin/env bash
    set -Eeuo pipefail
    
    if [[ ${EUID} -ne 0 ]]; then
      echo "root 권한으로 실행해야 합니다." >&2
      exit 1
    fi
    
    docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      exec -T -u www-data wordpress \
      php /var/www/html/wp-cron.php
    EOF
    
    sudo chmod 0755 /opt/ttakdap/scripts/wp-cron.sh
    sudo chown root:root /opt/ttakdap/scripts/wp-cron.sh
    sudo bash -n /opt/ttakdap/scripts/wp-cron.sh

    bash -n은 파일을 실행하지 않고 셸 문법만 확인한다.

    이제 service와 timer를 만든다.

    [SSH로 Ubuntu에서 실행]

    sudo tee /etc/systemd/system/ttakdap-wp-cron.service >/dev/null <<'EOF'
    [Unit]
    Description=Run due ttakdap WordPress cron events
    Requires=docker.service
    After=docker.service
    
    [Service]
    Type=oneshot
    User=root
    Group=root
    ExecStart=/opt/ttakdap/scripts/wp-cron.sh
    TimeoutStartSec=4m
    EOF
    
    sudo tee /etc/systemd/system/ttakdap-wp-cron.timer >/dev/null <<'EOF'
    [Unit]
    Description=Run ttakdap WordPress cron every five minutes
    
    [Timer]
    OnBootSec=5m
    OnUnitActiveSec=5m
    AccuracySec=30s
    Unit=ttakdap-wp-cron.service
    
    [Install]
    WantedBy=timers.target
    EOF
    
    sudo chmod 0644 \
      /etc/systemd/system/ttakdap-wp-cron.service \
      /etc/systemd/system/ttakdap-wp-cron.timer
    
    sudo systemd-analyze verify \
      /etc/systemd/system/ttakdap-wp-cron.service \
      /etc/systemd/system/ttakdap-wp-cron.timer
    
    sudo systemctl daemon-reload
    sudo systemctl start ttakdap-wp-cron.service
    
    systemctl show ttakdap-wp-cron.service \
      -p Result \
      -p ExecMainStatus \
      -p ExecMainStartTimestamp \
      -p ExecMainExitTimestamp

    service 실행 성공 기준은 Result=success, ExecMainStatus=0이다. 개별 플러그인의 예약 작업 결과까지 모두 검증했다는 뜻은 아니다. Type=oneshot 서비스는 일을 마치면 실행 중 상태를 유지하지 않으므로 inactive (dead)로 보여도 실패가 아니다.

    오류가 있으면 다음 로그를 본다.

    sudo journalctl \
      -u ttakdap-wp-cron.service \
      -n 50 \
      --no-pager

    아직 timer를 자동 시작하지 않은 이유는 다음 단계에서 기본 WP-Cron 호출을 끄기 전 마지막 검사를 하기 위해서다.

    4. systemd 성공을 확인한 뒤 기본 WP-Cron 호출 끄기

    WordPress 공식 안내는 시스템 스케줄러를 만든 뒤 DISABLE_WP_CRON을 켜서 페이지 요청에 의한 중복 호출을 멈추도록 설명한다. 순서를 반대로 하면 timer가 잘못됐을 때 예약 작업이 전부 멈출 수 있다.

    이 시리즈에서 사용한 WordPress 공식 이미지는 wp-config.php 안에서 WORDPRESS_CONFIG_EXTRA 환경값을 읽는다. 먼저 해당 연결이 남아 있는지 확인한다.

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      exec -T wordpress \
      grep -n 'WORDPRESS_CONFIG_EXTRA' /var/www/html/wp-config.php

    한 줄 이상 보이면 다음 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 토큰은 이 파일에 없으므로 출력하지 않는다.

    sudo grep -n -B 2 -A 4 \
      "DISABLE_WP_CRON" \
      /opt/ttakdap/compose.yaml
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      config --quiet
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      up -d --wait wordpress

    WordPress가 다시 healthy가 되면 실제 PHP 설정도 확인한다.

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      exec -T -u www-data wordpress \
      php -r '
        require "/var/www/html/wp-load.php";
        echo defined("DISABLE_WP_CRON") && DISABLE_WP_CRON
          ? "DISABLE_WP_CRON=true\n"
          : "DISABLE_WP_CRON=false\n";
      '

    DISABLE_WP_CRON=true가 보여야 한다. 이 값은 페이지 요청이 자동으로 Cron을 시작하는 동작을 끄는 것이지, wp-cron.php 파일을 삭제하거나 외부 URL 접근을 차단하는 설정은 아니다.

    Mac 브라우저에서도 공개 홈페이지가 그대로 열리고 /wp-admin/은 3편에서 만든 Cloudflare Access 인증 뒤 로그인 화면으로 이어지는지 확인한다. 둘 중 하나라도 달라졌다면 timer를 켜기 전에 Compose 변경과 WordPress 로그부터 확인한다.

    이제 timer를 활성화한다.

    sudo systemctl enable --now ttakdap-wp-cron.timer
    
    systemctl list-timers \
      ttakdap-wp-cron.timer \
      --all \
      --no-pager

    출력에 ttakdap-wp-cron.timer 한 줄, NEXT 시각, 연결된 ttakdap-wp-cron.service가 보여야 한다.

    OnBootSec=5m은 부팅 5분 뒤 첫 실행, OnUnitActiveSec=5m은 service가 최근 활성화된 시점부터 5분 간격으로 호출한다. 이미 부팅 5분이 지난 뒤 timer를 처음 켜면 첫 실행이 바로 잡힐 수도 있다. 서버가 꺼져 있던 동안의 5분 간격을 여러 번 재생하지는 않는다. WordPress 큐에 남은 마감 작업은 부팅 뒤 첫 wp-cron.php 실행에서 처리한다.

    5. 복구 세트에 무엇을 넣을지 먼저 정하기

    백업 스크립트를 보기 전에 결과물부터 구분하면 긴 명령의 목적이 보인다.

    WordPress 로컬 복구 세트에 포함되는 파일과 제외되는 비밀값

    그림 2. DB, WordPress 파일, 배포 정보와 체크섬은 같은 UTC 시각표에 둔다. 재발급하거나 따로 보관해야 하는 자격증명은 압축에서 제외한다.

    파일용도
    database.sql.gzMariaDB의 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. 백업 스크립트 전체를 만들기

    아래 스크립트는 한 번 실행할 때 다음 순서로 움직인다.

    1. 동시에 두 백업이 실행되지 않도록 잠금 파일을 잡는다.
    2. .partial-UTC시각 디렉터리에 MariaDB 덤프와 WordPress 파일을 만든다.
    3. 비밀값을 제외한 배포 설정과 컨테이너 이미지 정보를 기록한다.
    4. 각 파일의 SHA-256을 만들고 즉시 다시 검사한다.
    5. 모두 통과했을 때만 점이 없는 최종 디렉터리로 이름을 바꾼다.
    6. R2가 설정돼 있고 최근 24시간 안에 성공한 전송이 없으면 암호화해 올린다.
    7. 설정한 보존 기간이 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 화면에서 설정]

    1. Storage & databases → R2 → Overview로 이동한다.
    2. Create bucket을 눌러 백업 전용 버킷을 만든다.
    3. 공개 접근은 켜지 않는다. 사용자 지정 도메인도 필요 없다.
    4. R2 Overview의 Account Details에서 API Tokens 옆 Manage로 들어간다.
    5. Create Account API token을 선택한다.
    6. 권한은 Object Read & Write, 범위는 Apply to specific buckets only로 정한다.
    7. 방금 만든 백업 버킷 하나만 선택해 토큰을 만든다.

    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 원격 저장소에서 새 임시 디렉터리로 내려받고, 로컬 원본과 체크섬 파일이 같은지 비교한 뒤 압축 파일까지 연다.

    로컬 생성부터 R2 재다운로드까지의 검증 단계

    그림 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로 제한한다.

    [SSH로 Ubuntu에서 실행]

    sudo tee /etc/systemd/system/ttakdap-backup.service >/dev/null <<'EOF'
    [Unit]
    Description=ttakdap WordPress recovery-set backup
    Requires=docker.service
    After=docker.service network-online.target
    Wants=network-online.target
    
    [Service]
    Type=oneshot
    User=root
    Group=root
    UMask=0077
    Environment=BACKUP_ROOT=/var/backups/ttakdap
    ExecStart=/opt/ttakdap/scripts/backup.sh
    Nice=10
    IOSchedulingClass=best-effort
    IOSchedulingPriority=7
    EOF
    
    sudo tee /etc/systemd/system/ttakdap-backup.timer >/dev/null <<'EOF'
    [Unit]
    Description=Run ttakdap backup every six hours
    
    [Timer]
    OnCalendar=*-*-* 00,06,12,18:20:00
    RandomizedDelaySec=10m
    Persistent=true
    Unit=ttakdap-backup.service
    
    [Install]
    WantedBy=timers.target
    EOF
    
    sudo chmod 0644 \
      /etc/systemd/system/ttakdap-backup.service \
      /etc/systemd/system/ttakdap-backup.timer
    
    sudo systemd-analyze verify \
      /etc/systemd/system/ttakdap-backup.service \
      /etc/systemd/system/ttakdap-backup.timer
    
    systemd-analyze calendar \
      '*-*-* 00,06,12,18:20:00'
    
    sudo systemctl daemon-reload
    sudo systemctl start ttakdap-backup.service
    
    systemctl show ttakdap-backup.service \
      -p Result \
      -p ExecMainStatus \
      -p ExecMainStartTimestamp \
      -p ExecMainExitTimestamp
    
    sudo journalctl \
      -u ttakdap-backup.service \
      -n 80 \
      --no-pager

    Result=success, ExecMainStatus=0과 새 로컬 경로가 보여야 한다. 최근 24시간 안에 R2 업로드를 이미 성공했다면 이번 로그에는 Off-site upload skipped가 나올 수 있다. 오류가 아니라 일반 자동 실행에서는 하루 최대 한 세트만 보내도록 한 조건이 작동한 것이다.

    반대로 로컬 백업 뒤 R2 전송에서 실패하면 로컬 세트는 남아 있어도 service 결과는 실패한다. Local backup completed 한 줄만 보고 정상으로 판단하지 않고, journal의 끝과 마지막 R2 성공 시각을 기록한 /var/lib/ttakdap/last-offsite-upload 파일을 같이 본다.

    sudo stat /var/lib/ttakdap/last-offsite-upload

    수동 실행까지 성공했다면 timer를 활성화한다.

    sudo systemctl enable --now ttakdap-backup.timer
    
    systemctl is-enabled \
      ttakdap-wp-cron.timer \
      ttakdap-backup.timer
    
    systemctl is-active \
      ttakdap-wp-cron.timer \
      ttakdap-backup.timer
    
    systemctl list-timers \
      'ttakdap-*' \
      --all \
      --no-pager
    개인정보를 제외하고 재구성한 WP-Cron과 백업 타이머 정상 상태

    그림 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, 최근 종료 결과, 백업 디렉터리, 디스크를 함께 본다.

    [SSH로 Ubuntu에서 실행]

    systemctl list-timers \
      'ttakdap-*' \
      --all \
      --no-pager
    
    systemctl show ttakdap-wp-cron.service \
      -p Result \
      -p ExecMainStatus \
      -p ExecMainExitTimestamp
    
    sudo journalctl \
      -u ttakdap-wp-cron.service \
      --priority warning \
      -n 50 \
      --no-pager
    
    systemctl show ttakdap-backup.service \
      -p Result \
      -p ExecMainStatus \
      -p ExecMainExitTimestamp
    
    sudo journalctl \
      -u ttakdap-backup.service \
      -n 120 \
      --no-pager
    
    sudo find /var/backups/ttakdap \
      -mindepth 1 \
      -maxdepth 1 \
      -type d \
      -regextype posix-extended \
      -regex '.*/[0-9]{8}T[0-9]{6}Z' \
      -printf '%TY-%Tm-%Td %TH:%TM  %f\n' |
    sort
    
    df -h /
    sudo du -sh /var/backups/ttakdap

    다음 실행 시각만 있으면 충분하지 않다. 아래 중 하나면 자동화를 정상으로 간주하지 않고 원인부터 확인한다.

    • 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 → 백업 버킷 → Settings → Bucket lock rules → Add rule

    규칙이 유지되는 동안 객체 삭제·덮어쓰기 요청을 막아 운영 실수나 제한된 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 연결부터 본다.

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      ps
    
    sudo journalctl \
      -u ttakdap-wp-cron.service \
      -n 50 \
      --no-pager

    .partial-* 디렉터리만 남는다

    DB 덤프, 파일 압축 또는 체크섬 전에 중단됐다는 뜻이다. 남은 디렉터리를 완성 세트로 이름만 바꾸지 않는다. journal의 첫 오류와 디스크 여유를 확인한 뒤 새 백업을 실행한다.

    sudo find /var/backups/ttakdap \
      -mindepth 1 \
      -maxdepth 1 \
      -type d \
      -name '.partial-*' \
      -printf '%f\n'
    
    df -h /

    R2에서 403 Forbidden이 나온다

    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 성공 시각 파일을 함께 본다. 실패한 상태에서 보존 기간이나 원격 파일을 임의로 지우지 않는다.

    sudo journalctl \
      -u ttakdap-backup.service \
      -n 100 \
      --no-pager
    
    sudo stat /var/lib/ttakdap/last-offsite-upload

    crypt 복구 암호를 잃어버렸다

    R2 API 토큰만 새로 발급해도 기존 암호문은 복호화되지 않는다. VM 밖의 비밀번호 관리자 사본이 없다면 기존 백업을 읽을 수 없을 수 있다. 이런 상황을 피하려고 첫 설정 때 복구 암호를 별도로 보관하고, 정기적으로 재다운로드 시험을 한다.

    19. 자동화를 멈추거나 되돌리는 방법

    백업 자동화만 문제가 생겼다면 WordPress 예약 작업은 건드리지 않고 백업 timer만 멈춘다.

    [SSH로 Ubuntu에서 실행]

    sudo systemctl disable --now \
      ttakdap-backup.timer

    이 명령은 기존 로컬 복구 세트, R2 객체, WordPress 데이터 볼륨을 지우지 않는다.

    WP-Cron은 DISABLE_WP_CRON=true인 상태에서 timer부터 끄면 예약 작업이 전부 멈출 수 있다. 원래의 방문 요청 방식으로 되돌릴 때는 다음 순서를 지킨다.

    1. /opt/ttakdap/compose.yaml과, 수동으로 수정했다면 컨테이너의 wp-config.php에서 이 글이 추가한 DISABLE_WP_CRON 보호 블록만 제거한다.
    2. PHP·Compose 문법을 확인하고 WordPress를 재생성한다.
    3. 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 볼륨에 파일을 푼 뒤 대표 글·이미지·관리자 로그인을 확인해야 비로소 실제 복원 절차라고 부를 수 있다. 운영 서버에 바로 덮어쓰지 않고 별도 환경에서 진행할 내용이다.

    참고한 공식 문서

  • Cloudflare Tunnel로 WordPress 공개하기: 포트포워딩 없이 wp-admin 보호까지

    Cloudflare Tunnel로 WordPress 공개하기: 포트포워딩 없이 wp-admin 보호까지

    2편을 마쳤을 때 MariaDB와 WordPress 컨테이너는 둘 다 healthy였지만 브라우저에서는 사이트가 열리지 않았다. 실패한 것이 아니라 의도한 상태였다. WordPress의 80번 포트를 Ubuntu나 공유기에 공개하지 않았기 때문이다.

    이번에는 그 상태를 그대로 유지한 채 cloudflared 컨테이너만 추가한다. 공유기에서 80·443 포트를 열어 외부 요청을 직접 받는 대신, cloudflared가 Cloudflare 쪽으로 먼저 연결하고 방문 요청을 WordPress에 전달하게 한다.

    가장 신경 쓴 부분은 Tunnel을 켜는 명령보다 WordPress 초기 설치 화면을 언제 공개할지였다. 공개 주소부터 만든 다음 관리자 계정을 천천히 설정하면, 그 사이 초기 설치 화면이 인터넷에 노출될 수 있다. 그래서 먼저 사이트 전체에 임시 Cloudflare Access를 만들고, WordPress 설치가 끝난 뒤 공개 글과 관리자 경로를 분리했다.

    공유기 포트를 열지 않고 Cloudflare Tunnel로 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가 서로 다른 메뉴에 있기 때문이다.

    화면이 글에서 하는 일2026년 기준 메뉴 예시
    일반 Cloudflare 대시보드Tunnel, DNS, HTTPS, RedirectNetworking → Tunnels, DNS, SSL/TLS, Rules
    Cloudflare Zero Trust로그인 정책과 보호 경로Access controls → Applications, Integrations → Identity providers

    일부 계정에서는 Tunnel이 Zero Trust의 Networks → Connectors에 보이거나, Published application이 이전 이름인 Public Hostnames로 보일 수 있다. 메뉴 이름이 조금 달라도 원격 관리 Tunnel, Published application, Self-hosted Access application이라는 기능을 찾으면 된다.

    Cloudflare의 세 기능도 역할이 다르다.

    기능하는 일하지 않는 일
    DNS도메인 요청을 Cloudflare로 안내WordPress 컨테이너에 직접 연결하지 않음
    TunnelCloudflare와 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 쪽을 나눠 볼 수 있다.

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      config --quiet &&
    echo "기존 Compose 문법 확인 완료"
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      ps

    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 화면에서 설정]

    1. Zero Trust → Integrations → Identity providers로 이동한다.
    2. Add new identity provider를 누른다.
    3. One-time PIN을 추가한다.

    2026년의 새 Zero Trust 조직에는 OTP가 자동으로 추가되지 않을 수 있다. Access 애플리케이션에서 로그인 방식 목록에 One-time PIN이 없다면 이 단계부터 진행한다.

    사이트 전체 임시 보호 만들기

    [Cloudflare 화면에서 설정]

    1. Zero Trust → Access controls → Applications로 이동한다.
    2. Create new application을 누른다.
    3. Self-hosted and private를 선택한다.
    4. 이름을 WordPress 초기 설치 임시 보호처럼 알아보기 쉽게 적는다.
    5. Add public hostname에서 사용할 도메인을 선택한다.
    6. 사이트 전체가 대상이 되도록 Path는 비워 둔다.
    7. 정책 Action은 Allow로 둔다.
    8. Include는 Emails를 선택하고 실제 소유자 이메일을 정확히 입력한다.
    9. 로그인 방식으로 One-time PIN 또는 사용 중인 IdP를 선택한다.
    10. 애플리케이션을 저장한다.

    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 화면에서 설정]

    1. 일반 Cloudflare 대시보드의 Networking → Tunnels로 이동한다.
    2. Create a tunnel을 누른다.
    3. Tunnel 이름을 wordpress-home-server처럼 용도가 보이게 적는다.
    4. Tunnel을 만든 뒤 실행 환경에서 Docker를 선택한다.
    5. 화면에 표시된 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다.

    [SSH로 Ubuntu에서 실행]

    sudo docker pull cloudflare/cloudflared:2026.8.2
    
    sudo docker image inspect \
      --format 'cloudflared 실행 사용자={{.Config.User}}' \
      cloudflare/cloudflared:2026.8.2

    정상 결과는 다음과 같다.

    cloudflared 실행 사용자=65532

    뒤에서 토큰 파일의 소유자를 65532:65532로 만드는 이유다. 다른 값이 나오면 아래 권한 명령을 그대로 실행하지 말고, 선택한 이미지의 공식 정보와 실행 사용자를 다시 확인한다.

    4. Tunnel 토큰을 권한 제한 파일로 저장하기

    이 블록은 Cloudflare가 보여 준 Docker 명령 전체가 아니라, --token 뒤의 긴 값만 입력받는다. read -s를 사용하므로 붙여넣어도 화면과 셸 기록에 토큰이 나타나지 않는다.

    [SSH로 Ubuntu에서 실행]

    sudo -v
    ORIGINAL_UMASK="$(umask)"
    umask 077
    
    TOKEN_TMP="$(mktemp)"
    trap 'rm -f -- "$TOKEN_TMP"; umask "$ORIGINAL_UMASK"' EXIT HUP INT TERM
    
    IFS= read -r -s -p "Cloudflare Tunnel token 붙여넣기: " TUNNEL_TOKEN
    printf '\n'
    
    if (( ${#TUNNEL_TOKEN} < 80 )) ||
       [[ "$TUNNEL_TOKEN" =~ [[:space:]] ]]; then
      unset TUNNEL_TOKEN
      echo "토큰 형식 확인이 필요합니다. 파일은 만들지 않았습니다."
    else
      printf '%s' "$TUNNEL_TOKEN" > "$TOKEN_TMP"
      unset TUNNEL_TOKEN
    
      sudo install -d -m 0700 -o root -g root \
        /opt/ttakdap/secrets
    
      sudo install -m 0400 -o 65532 -g 65532 \
        "$TOKEN_TMP" \
        /opt/ttakdap/secrets/cloudflare_tunnel_token.txt
    
      sudo stat \
        -c 'uid=%u gid=%g mode=%a bytes=%s %n' \
        /opt/ttakdap/secrets/cloudflare_tunnel_token.txt
    fi
    
    rm -f -- "$TOKEN_TMP"
    umask "$ORIGINAL_UMASK"
    trap - EXIT HUP INT TERM
    unset ORIGINAL_UMASK TOKEN_TMP TUNNEL_TOKEN

    정상 결과에는 다음 값이 보인다.

    uid=65532 gid=65532 mode=400 bytes=충분히_긴_숫자 /opt/ttakdap/secrets/cloudflare_tunnel_token.txt

    바이트 수는 토큰마다 달라질 수 있다. UID와 GID가 65532, mode가 400, bytes가 80보다 큰지만 확인한다. 내용을 확인하겠다며 cat을 실행하면 토큰이 화면과 터미널 기록에 남으므로 하지 않는다.

    root:root 0400으로 만들면 비root UID 65532로 실행되는 cloudflared가 읽지 못한다. 반대로 모두가 읽을 수 있는 0644로 만들면 같은 Ubuntu의 다른 사용자와 프로세스에 토큰이 노출될 수 있다.

    5. cloudflared를 포함한 Compose 완성본 만들기

    긴 YAML 중간에 몇 줄을 끼워 넣으면 들여쓰기 위치를 틀리기 쉽다. 아래에는 2편의 Compose 내용을 유지하면서 cloudflared 서비스와 Tunnel secret만 추가한 전체 파일을 싣는다.

    2편에서 만든 ${SITE_URL:?SITE_URL is required in .env} 검사와 WordPress 설정은 그대로 두었다. WP-Cron은 4편에서 systemd 타이머를 만든 뒤 전환하므로 이번 파일에서 미리 비활성화하지 않는다.

    아래 블록은 첫 줄부터 마지막 EOF까지 한 번에 복사한다.

    [SSH로 Ubuntu에서 실행]

    sudo tee /opt/ttakdap/compose.yaml >/dev/null <<'EOF'
    name: ttakdap
    
    services:
      db:
        image: mariadb:11.4.13
        restart: unless-stopped
        environment:
          MARIADB_DATABASE: wordpress
          MARIADB_USER: wordpress
          MARIADB_PASSWORD_FILE: /run/secrets/db_password
          MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
          TZ: ${TZ:-Asia/Seoul}
        secrets:
          - db_password
          - db_root_password
        volumes:
          - mariadb_data:/var/lib/mysql
          - ./config/mariadb.cnf:/etc/mysql/conf.d/zz-ttakdap.cnf:ro
        networks:
          - db_internal
        healthcheck:
          test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
          interval: 10s
          timeout: 5s
          retries: 12
          start_period: 40s
        stop_grace_period: 1m
        mem_limit: 2g
        pids_limit: 200
        security_opt:
          - no-new-privileges:true
        logging:
          driver: json-file
          options:
            max-size: 10m
            max-file: "5"
    
      wordpress:
        image: wordpress:7.1.0-php8.4-apache
        restart: unless-stopped
        environment:
          WORDPRESS_DB_HOST: db:3306
          WORDPRESS_DB_USER: wordpress
          WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
          WORDPRESS_DB_NAME: wordpress
          WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
          WORDPRESS_CONFIG_EXTRA: |
            define( 'WP_HOME', '${SITE_URL:?SITE_URL is required in .env}' );
            define( 'WP_SITEURL', '${SITE_URL:?SITE_URL is required in .env}' );
            define( 'FORCE_SSL_ADMIN', true );
            define( 'DISALLOW_FILE_EDIT', true );
            define( 'WP_AUTO_UPDATE_CORE', 'minor' );
            define( 'WP_POST_REVISIONS', 20 );
            define( 'WP_ENVIRONMENT_TYPE', 'production' );
            define( 'WP_DEBUG_DISPLAY', false );
            @ini_set( 'display_errors', 0 );
          TZ: ${TZ:-Asia/Seoul}
        secrets:
          - db_password
        volumes:
          - wordpress_data:/var/www/html
          - ./config/php.ini:/usr/local/etc/php/conf.d/zz-ttakdap.ini:ro
        networks:
          - db_internal
          - edge
        depends_on:
          db:
            condition: service_healthy
        healthcheck:
          test:
            - CMD-SHELL
            - >-
              php -r '$$socket = @fsockopen("127.0.0.1", 80);
              exit($$socket ? 0 : 1);'
          interval: 15s
          timeout: 5s
          retries: 12
          start_period: 60s
        stop_grace_period: 30s
        mem_limit: 2g
        pids_limit: 300
        security_opt:
          - no-new-privileges:true
        logging:
          driver: json-file
          options:
            max-size: 10m
            max-file: "5"
    
      cloudflared:
        image: cloudflare/cloudflared:2026.8.2
        profiles: ["tunnel"]
        restart: unless-stopped
        command: ["tunnel", "--no-autoupdate", "run"]
        environment:
          TUNNEL_TOKEN_FILE: /run/secrets/cloudflare_tunnel_token
        secrets:
          - cloudflare_tunnel_token
        networks:
          - edge
        depends_on:
          wordpress:
            condition: service_healthy
        read_only: true
        cap_drop:
          - ALL
        security_opt:
          - no-new-privileges:true
        tmpfs:
          - /tmp:rw,noexec,nosuid,nodev,size=16m
        mem_limit: 256m
        pids_limit: 100
        logging:
          driver: json-file
          options:
            max-size: 10m
            max-file: "5"
    
    networks:
      db_internal:
        internal: true
      edge:
    
    volumes:
      mariadb_data:
      wordpress_data:
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt
      db_root_password:
        file: ./secrets/db_root_password.txt
      cloudflare_tunnel_token:
        file: ./secrets/cloudflare_tunnel_token.txt
    EOF
    
    sudo chmod 0640 /opt/ttakdap/compose.yaml
    sudo chown root:root /opt/ttakdap/compose.yaml

    추가된 핵심 설정의 의미는 다음과 같다.

    설정의미
    profiles: ["tunnel"]토큰 준비 전 일반 up 명령으로 Tunnel이 실수로 시작되지 않게 분리
    TUNNEL_TOKEN_FILE토큰 값을 Compose나 환경변수 내용에 직접 적지 않고 파일에서 읽음
    edgecloudflared와 WordPress만 공유하는 Docker 네트워크
    http://wordpress:80뒤에서 Published application이 사용할 Docker 내부 서비스 주소
    depends_onWordPress가 healthy가 된 뒤 connector 시작
    read_only, cap_drop, no-new-privilegescloudflared 컨테이너 권한과 쓰기 범위를 줄임
    --no-autoupdate컨테이너 안에서 자체 갱신하지 않고 검토한 이미지 태그로 교체
    ports 없음VM과 공유기에 WordPress·DB 포트를 계속 공개하지 않음

    Cloudflared는 edge에만 연결하고 db_internal에는 연결하지 않는다. Tunnel 요청은 WordPress까지 갈 수 있지만 MariaDB로 직접 갈 수 없다.

    6. Compose 문법과 토큰 참조 확인하기

    파일을 저장했다고 바로 시작하지 않는다. Tunnel profile까지 포함해 YAML과 파일 참조를 먼저 검사한다.

    [SSH로 Ubuntu에서 실행]

    sudo test -s \
      /opt/ttakdap/secrets/cloudflare_tunnel_token.txt &&
    echo "Tunnel token 파일 존재 확인"
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      config --quiet &&
    echo "Tunnel 포함 Compose 문법 확인 완료"

    정상 결과는 다음 두 줄이다.

    Tunnel token 파일 존재 확인
    Tunnel 포함 Compose 문법 확인 완료

    이 검사는 토큰이 Cloudflare에서 유효한지까지 확인하지 않는다. 파일 존재와 Compose 문법을 먼저 확인해, connector가 실패했을 때 확인 범위를 줄이는 단계다.

    7. cloudflared connector 시작하기

    Tunnel profile의 cloudflared만 시작한다. WordPress와 DB는 설정이 바뀌지 않았으므로 상태가 정상이라면 다시 만들 필요가 없다.

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      up -d cloudflared

    잠시 뒤 세 컨테이너 상태를 본다.

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      ps

    db와 wordpress는 healthy, cloudflared는 Up 또는 running이어야 한다. Cloudflared에는 이 글에서 별도 healthcheck를 넣지 않았으므로 running만 보고 끝내지 않는다.

    재시작 횟수도 확인한다.

    [SSH로 Ubuntu에서 실행]

    CLOUDFLARED_ID="$(
      sudo docker compose \
        --env-file /opt/ttakdap/.env \
        -f /opt/ttakdap/compose.yaml \
        --profile tunnel \
        ps -q cloudflared
    )"
    
    if [[ -n "$CLOUDFLARED_ID" ]]; then
      sudo docker inspect \
        --format '상태={{.State.Status}} 재시작 횟수={{.RestartCount}}' \
        "$CLOUDFLARED_ID"
    else
      echo "cloudflared 컨테이너를 찾지 못했습니다."
    fi
    
    unset CLOUDFLARED_ID

    정상이라면 상태=running 재시작 횟수=0처럼 나온다. 재시작 횟수가 계속 늘면 최근 로그부터 본다.

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      logs --tail 50 cloudflared 2>&1 |
    sed -E \
      's/eyJ[A-Za-z0-9._=-]+/[TUNNEL_TOKEN_REDACTED]/g'

    Registered tunnel connection과 연결 정보가 반복해서 보이면 connector가 Cloudflare에 연결된 것이다. 로그 전체를 그대로 온라인에 올리면 계정·Tunnel·네트워크 식별 정보가 포함될 수 있으므로 필요한 줄만 남기고 가린다.

    [Cloudflare 화면에서 확인]

    Networking → Tunnels에서 방금 만든 Tunnel 상태가 Healthy인지 확인한다. Healthy는 Cloudflare와 connector가 연결됐다는 뜻이지, 아직 도메인과 WordPress가 정상이라는 뜻은 아니다.

    8. Published application을 wordpress:80에 연결하기

    Tunnel이 Healthy가 된 뒤 공개 호스트를 추가한다.

    [Cloudflare 화면에서 설정]

    1. Networking → Tunnels에서 만든 Tunnel을 선택한다.
    2. Routes 탭을 연다.
    3. Add route → Published application을 선택한다.
    4. Hostname은 루트 도메인을 선택하고 Subdomain과 Path는 비워 둔다.
    5. Service type은 HTTP를 선택한다.
    6. Service URL에는 wordpress:80을 입력한다.
    7. 최종 표시가 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 로그인 쿠키가 남아 있을 수 있으므로 새 시크릿 창을 사용한다.

    [브라우저에서 확인]

    1. 새 시크릿 창에서 https://내도메인을 연다.
    2. Cloudflare Access 로그인 화면이 나오는지 확인한다.
    3. Allow 정책에 등록한 정확한 이메일을 입력한다.
    4. 받은 One-time PIN으로 로그인한다.
    5. 그 다음 WordPress 초기 설치 화면이 나오는지 확인한다.

    OTP 메일은 정책에 허용된 이메일에만 발송된다. 허용되지 않은 주소를 입력해도 계정 존재 여부를 숨기기 위해 화면에는 “코드를 보냈다”고 표시될 수 있다.

    메일이 오지 않으면 다음 순서로 확인한다.

    • Access Allow 정책의 이메일 철자와 입력한 주소가 완전히 같은지 확인한다.
    • One-time PIN identity provider가 애플리케이션에 선택됐는지 확인한다.
    • 스팸함과 메일 보안 필터를 확인한다.
    • 새 PIN을 요청했다면 이전 PIN은 무효가 됐으므로 가장 최근 코드만 사용한다.
    • PIN은 발급 후 10분 안에 사용한다.

    WordPress 설치 화면에서는 다음 항목을 정한다.

    항목권장 방식
    사이트 제목운영할 실제 사이트 이름
    관리자 사용자명admin, 도메인명처럼 추측하기 쉬운 이름을 피함
    관리자 비밀번호다른 서비스와 겹치지 않는 긴 무작위 비밀번호
    관리자 이메일실제 알림을 받을 주소를 정확히 입력
    검색 엔진 표시공개 준비 상태에 맞춰 선택하고 발행 전에 다시 확인

    Cloudflare 계정 비밀번호와 WordPress 비밀번호를 같게 만들지 않는다. 생성한 WordPress 비밀번호는 비밀번호 관리자에 저장하고, 비밀번호가 표시된 설치 화면은 캡처하지 않는다.

    설치가 끝나면 Access를 통과한 같은 창에서 WordPress 관리자 로그인이 되는지 확인한다. 주소 표시줄이 https://이고, WordPress가 HTTP 주소로 되돌리지 않는지도 본다.

    10. 전체 잠금에서 관리자 전용 잠금으로 전환하기

    WordPress 설치가 끝났다고 임시 전체 사이트 Access부터 지우면 안 된다. 관리자 보호 경로를 먼저 만든 뒤 새 시크릿 창에서 확인하고, 필요한 공개 예외가 있는지 판단한 다음 마지막에 전체 잠금을 제거한다.

    Mac에서 현재 WordPress 공개 페이지와 관리자 경로의 Cloudflare Access 상태를 확인한 결과

    그림 2. 2026년 9월 1일 Mac에서 쓰기 없는 HEAD 요청으로 확인한 현재 상태다. 홈페이지는 바로 열리고 로그인·관리자·admin-ajax 경로는 Access 인증을 먼저 거친다. Access 주소와 식별 정보는 화면에서 제외했다.

    이 결과에서 ADMIN_AJAX ACCESS_LOGIN은 오류가 아니다. 현재 공개 화면에서 이 경로에 의존하는 기능을 쓰지 않기 때문에 더 넓게 열지 않았다. 댓글, 문의 폼, 검색 필터 같은 공개 기능이 실제로 멈추고 그 요청이 admin-ajax.php로 가는 것이 확인될 때만 아래 예외를 추가한다.

    관리자 경로 Access 만들기

    [Cloudflare 화면에서 설정]

    1. Zero Trust → Access controls → Applications로 이동한다.
    2. 새 Self-hosted and private 애플리케이션을 만든다.
    3. 이름을 WordPress 관리자 보호로 적는다.
    4. 같은 도메인에 아래 세 경로를 Public hostname으로 추가한다.
    5. 정책 Action은 Allow, Include는 소유자의 정확한 Emails로 설정한다.
    6. 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 화면에서 설정]

    1. 새 시크릿 창에서 댓글, 문의 폼, 검색 필터처럼 로그인하지 않은 방문자가 쓰는 기능을 실행한다.
    2. 브라우저 개발자 도구의 Network에서 실패한 요청이 정확히 /wp-admin/admin-ajax.php인지 확인한다.
    3. 그 요청이 Cloudflare Access 로그인으로 리디렉션돼 기능이 멈춘 경우에만 이 경로를 대상으로 별도의 Self-hosted Access 애플리케이션을 만든다.
    4. 정책 Action은 Bypass, Include는 Everyone으로 설정한다.
    5. 경로가 정확히 /wp-admin/admin-ajax.php인지 다시 확인하고 저장한다.
    6. 같은 공개 기능을 새 시크릿 창에서 다시 실행해 정상 동작하는지 확인한다.

    더 구체적인 경로가 넓은 /wp-admin/* 규칙보다 우선한다. Bypass는 해당 요청에 Access 인증과 Access 로그를 적용하지 않으므로 /wp-admin/* 전체에 사용하면 안 된다.

    /wp-admin/admin-post.php는 공개 폼이나 플러그인이 실제로 요구할 때만 같은 방식의 정확한 경로 예외를 추가한다. 필요 여부를 확인하지 않고 미리 넓히지 않는다.

    새 규칙을 먼저 시험하기

    아직 임시 전체 사이트 Access는 남아 있으므로 공개 페이지까지 인증을 요구하는 것이 정상이다. 먼저 관리자 경로 세 개가 저장됐는지 확인한다. admin-ajax.php 예외는 앞 단계에서 필요성이 확인된 경우에만 함께 확인한다.

    그 다음 임시 전체 사이트 애플리케이션을 제거하거나 비활성화해 공개 전환한다. 전환 직후 새 시크릿 창에서 다시 확인한다.

    주소기대 결과
    /와 대표 글Access 로그인 없이 공개
    이미지와 /wp-json/Access 로그인 없이 공개
    /robots.txt, sitemapAccess 로그인 없이 공개
    /wp-login.phpCloudflare 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 화면에서 설정]

    1. 같은 Tunnel에 www Published application을 추가한다.
    2. Service URL은 루트 도메인과 같은 http://wordpress:80으로 둔다.
    3. Rules → Redirect Rules → Single Redirects로 이동한다.
    4. 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을 자신의 도메인으로 바꾼다.

    이 규칙을 사용하면 다음처럼 경로와 쿼리를 보존할 수 있다.

    https://www.example.com/post/?page=2
    → https://example.com/post/?page=2

    HTTP 요청을 HTTPS로 보내는 설정은 원본 Apache가 아니라 Cloudflare에서 처리한다.

    [Cloudflare 화면에서 설정]

    1. SSL/TLS → Edge Certificates에서 Universal SSL이 Active인지 확인한다.
    2. SSL/TLS 모드가 Off가 아닌지 확인한다.
    3. 루트 주소, www 리디렉션, 관리자 Access, 이미지가 모두 정상인지 시험한다.
    4. Always Use HTTPS를 켠다.
    5. HTTP 주소가 HTTPS로 한 번만 리디렉션되는지 다시 확인한다.

    브라우저와 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를 모두 바꾸면 원인을 찾기 어려워진다. 앞 단계에서 확인한 사실은 유지하고 다음 연결만 본다.

    Cloudflare Tunnel 문제를 세 구간으로 나누어 확인하는 순서

    그림 4. Tunnel이 연결되지 않은 문제, WordPress까지 가지 못하는 문제, Access 정책 문제를 서로 다른 구간으로 나눠 확인한다.

    Tunnel이 Inactive 또는 Down이다

    Cloudflare와 connector 사이 문제다. 아직 WordPress 설정을 바꾸지 않는다.

    1. cloudflared 컨테이너가 반복 재시작하는지 확인한다.
    2. 토큰 파일 UID/GID가 65532:65532, mode가 400인지 확인한다.
    3. Cloudflare에서 복사한 토큰이 현재 Tunnel의 토큰인지 확인한다.
    4. 토큰을 회전했다면 파일도 새 토큰으로 바꾸고 컨테이너를 다시 만든다.
    5. Ubuntu에서 Cloudflare로 나가는 TCP·UDP 7844가 차단됐는지 확인한다.

    [SSH로 Ubuntu에서 실행]

    sudo stat \
      -c 'uid=%u gid=%g mode=%a bytes=%s %n' \
      /opt/ttakdap/secrets/cloudflare_tunnel_token.txt
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      logs --tail 100 cloudflared 2>&1 |
    sed -E \
      's/eyJ[A-Za-z0-9._=-]+/[TUNNEL_TOKEN_REDACTED]/g'

    Cloudflare 오류 1033은 Cloudflare가 정상 connector를 찾지 못하는 구간을 먼저 봐야 한다.

    Tunnel은 Healthy인데 502가 나온다

    Cloudflare와 connector 연결은 됐지만 connector가 WordPress에 도달하지 못한 상태다.

    1. Published application이 http://wordpress:80인지 확인한다.
    2. localhost, VM IP, https://wordpress로 적지 않았는지 확인한다.
    3. WordPress와 cloudflared가 모두 edge 네트워크에 연결됐는지 확인한다.
    4. WordPress가 healthy인지 확인한다.

    [SSH로 Ubuntu에서 실행]

    sudo docker inspect \
      --format '{{.Name}} → {{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}' \
      "$(sudo docker compose \
        --env-file /opt/ttakdap/.env \
        -f /opt/ttakdap/compose.yaml \
        --profile tunnel ps -q wordpress)" \
      "$(sudo docker compose \
        --env-file /opt/ttakdap/.env \
        -f /opt/ttakdap/compose.yaml \
        --profile tunnel ps -q cloudflared)"

    두 컨테이너 결과에 같은 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 볼륨은 건드리지 않는다.

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      stop cloudflared
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      --profile tunnel \
      rm -f cloudflared

    2편 Compose 백업이 있을 때만 원본을 복원한다.

    [SSH로 Ubuntu에서 실행]

    if sudo test -s /opt/ttakdap/compose.before-cloudflared.yaml; then
      sudo cp -a \
        /opt/ttakdap/compose.before-cloudflared.yaml \
        /opt/ttakdap/compose.yaml
    
      sudo docker compose \
        --env-file /opt/ttakdap/.env \
        -f /opt/ttakdap/compose.yaml \
        config --quiet &&
      echo "2편 Compose 복원 확인"
    
      sudo docker compose \
        --env-file /opt/ttakdap/.env \
        -f /opt/ttakdap/compose.yaml \
        up -d --wait db wordpress
    else
      echo "Compose 백업을 찾지 못해 자동 복원하지 않았습니다."
    fi

    docker compose down -v나 docker system prune --volumes는 롤백 명령이 아니다. 실행하면 WordPress와 MariaDB 데이터 볼륨을 잃을 수 있으므로 사용하지 않는다.

    Tunnel을 완전히 폐기한다면 Cloudflare에서 먼저 토큰을 회전하거나 Tunnel을 삭제한 뒤 로컬 토큰 파일을 별도로 정리한다. 단순 진단 중에는 토큰 파일부터 지우지 않는다.

    3편 완료 점검표

    • ☐ 2편의 db와 wordpress가 먼저 healthy였다.
    • ☐ Tunnel 추가 전 Compose 원본과 VM 복구 지점을 만들었다.
    • ☐ 원격 관리 Named Tunnel을 만들었다.
    • ☐ Published application보다 전체 사이트 임시 Access를 먼저 만들었다.
    • ☐ Allow 정책에는 소유자의 정확한 이메일만 넣었다.
    • ☐ Tunnel 토큰을 명령줄, .env, Compose에 직접 적지 않았다.
    • ☐ 토큰 파일은 UID/GID 65532:65532, mode 0400이다.
    • ☐ Cloudflared는 edge에만 연결되고 MariaDB 내부 네트워크에는 연결되지 않았다.
    • ☐ Published application의 원본은 http://wordpress:80이다.
    • ☐ Tunnel 대시보드에서 Healthy를 확인했다.
    • ☐ Access를 통과한 뒤 WordPress 초기 설치와 관리자 로그인을 마쳤다.
    • ☐ /wp-login.php, /wp-admin, /wp-admin/*를 보호했다.
    • ☐ /wp-admin/admin-ajax.php는 보호 상태로 두었거나, 공개 기능에 필요함을 확인한 뒤 정확한 경로만 Bypass했다.
    • ☐ 전체 사이트 임시 Access를 제거한 뒤 공개 글이 인증 없이 열린다.
    • ☐ 새 시크릿 창에서 공개 페이지와 관리자 경로를 각각 확인했다.
    • ☐ www 리디렉션은 path와 query를 보존한다.
    • ☐ Always Use HTTPS를 켠 뒤 중복 리디렉션이 없는지 확인했다.
    • ☐ HSTS는 충분한 검증 뒤로 미뤘다.
    • ☐ 공유기·Mac·Ubuntu·Compose에 80·443 포트포워딩을 만들지 않았다.

    다음 글에서 할 일

    이제 WordPress는 외부에서 열리지만 운영 준비가 끝난 것은 아니다. 4편에서는 방문 요청에 의존하는 기본 WP-Cron을 systemd 타이머로 바꾸고, MariaDB 논리 덤프와 WordPress 파일을 함께 백업한다.

    다음 글의 목표는 다음과 같다.

    1. 서버 시간과 systemd 타이머 상태 확인
    2. WP-Cron을 외부 요청과 분리
    3. MariaDB 일관성 덤프 만들기
    4. WordPress 파일과 설정 함께 보관하기
    5. 백업 체크섬 검증하기
    6. 로컬 보존 기간 적용하기
    7. rclone crypt로 암호화해 Cloudflare R2에 하루 최대 한 세트 전송하기
    8. 실제 파일을 다시 내려받아 복호화와 복원 재료 확인하기

    다음 글: WP-Cron을 systemd로 바꾸고 Cloudflare R2에 백업하기

    공식 참고 자료

  • 맥미니 Ubuntu에 Docker로 WordPress 설치하기: MariaDB와 분리해 내부 구동까지

    맥미니 Ubuntu에 Docker로 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을 연결하지 않는다.

    전체 구조와 이 방식을 선택한 이유는 맥미니를 24시간 홈서버로 써도 될까?에서 먼저 볼 수 있다. Ubuntu VM이 아직 없다면 1편: Parallels에 Ubuntu Server 설치하기부터 진행해야 한다.

    시작 전에 확인할 조건

    이 글은 1편에서 만든 새 Ubuntu VM을 기준으로 한다.

    • Ubuntu Server 24.04 LTS ARM64가 실행 중이다.
    • VM에는 4 vCPU, 메모리 8GB, 확장형 디스크 64GB를 할당했다.
    • Mac에서 Ubuntu로 전용 SSH 키 접속이 된다.
    • Parallels의 Ubuntu 콘솔로도 로그인할 수 있다.
    • 공유기와 VM에 80·443 포트포워딩을 만들지 않았다.
    • 기존 Docker 컨테이너나 보존해야 할 Docker 데이터가 없다.

    이미 다른 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

    이제 같은 터미널 창이라도 명령은 Ubuntu에서 실행된다.

    [SSH로 Ubuntu에서 실행]

    uname -s
    . /etc/os-release
    printf '%s (%s)\n' "$PRETTY_NAME" "$VERSION_CODENAME"
    dpkg --print-architecture
    df -h /
    free -h

    이 구성의 핵심 결과는 다음과 같다.

    Linux
    Ubuntu 24.04 LTS (noble)
    arm64

    Ubuntu의 세부 개정 번호는 ISO 업데이트에 따라 다를 수 있다. 디스크가 거의 가득 찼거나 운영체제·CPU 종류가 다르면 그대로 진행하지 않는다.

    기존 Docker 관련 패키지도 확인한다.

    dpkg -l |
    awk '
      $1 == "ii" &&
      $2 ~ /^(docker.io|docker-compose|docker-compose-v2|docker-doc|docker-buildx|podman-docker|containerd|runc)$/ {
        print $2, $3
      }
    '

    1편에서 만든 새 VM이라면 보통 아무것도 출력되지 않는다. 패키지 이름이 나온다면 자동으로 지우지 말고 기존 Docker 데이터가 있는지 먼저 확인한다. Docker 공식 문서도 배포판 패키지와 공식 패키지의 충돌 가능성을 안내한다.

    2. Docker Engine과 Compose 직접 설치하기

    Docker 공식 문서가 안내하는 APT 저장소 방식을 사용한다. curl ... | sh처럼 내려받은 설치 스크립트를 바로 실행하지 않는다. 명령이 조금 길어도 어떤 저장소에서 어떤 패키지를 설치하는지 눈으로 확인할 수 있다.

    Docker 저장소의 서명키 준비

    먼저 패키지 목록을 갱신하고 HTTPS 통신과 비밀번호 생성에 쓸 도구를 설치한다.

    [SSH로 Ubuntu에서 실행]

    sudo apt update
    sudo apt install -y ca-certificates curl openssl

    Docker 저장소의 서명키를 저장한다.

    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL \
      https://download.docker.com/linux/ubuntu/gpg \
      -o /etc/apt/keyrings/docker.asc
    sudo chmod a+r /etc/apt/keyrings/docker.asc

    이 키는 APT가 내려받은 패키지가 Docker에서 배포한 것인지 확인할 때 사용한다. 키 파일이 생겼는지는 내용 대신 파일 정보로 확인한다.

    ls -l /etc/apt/keyrings/docker.asc

    Docker 공식 APT 저장소 등록

    아래 블록은 Ubuntu 코드명과 CPU 종류를 현재 VM에서 자동으로 읽는다. 첫 줄부터 마지막 EOF까지 한 번에 복사한다.

    sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
    Types: deb
    URIs: https://download.docker.com/linux/ubuntu
    Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
    Components: stable
    Architectures: $(dpkg --print-architecture)
    Signed-By: /etc/apt/keyrings/docker.asc
    EOF
    
    sudo apt update

    정상이라면 apt update 출력에 download.docker.com/linux/ubuntu가 나타나고 오류 없이 끝난다. 저장소 주소를 찾지 못한다는 오류가 나오면 WordPress 단계로 넘어가지 말고 VM의 인터넷 연결과 앞에서 만든 파일을 먼저 확인한다.

    Docker 패키지 설치

    sudo apt install -y \
      docker-ce \
      docker-ce-cli \
      containerd.io \
      docker-buildx-plugin \
      docker-compose-plugin

    각 패키지의 역할은 다음과 같다.

    패키지하는 일
    docker-ce컨테이너를 실제로 실행하는 Docker Engine
    docker-ce-clidocker 명령 제공
    containerd.io컨테이너 실행과 수명 주기 관리
    docker-buildx-pluginDocker 이미지 빌드 기능
    docker-compose-plugincompose.yaml에 적힌 여러 서비스를 함께 관리

    예전 글에 나오는 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로 둔다.

    [SSH로 Ubuntu에서 실행]

    sudo install -d -m 0750 -o root -g root /opt/ttakdap
    sudo install -d -m 0750 -o root -g root /opt/ttakdap/config
    sudo install -d -m 0700 -o root -g root /opt/ttakdap/secrets

    권한을 확인한다.

    sudo stat -c '%U %G %a %n' \
      /opt/ttakdap \
      /opt/ttakdap/config \
      /opt/ttakdap/secrets

    핵심은 다음 세 줄이다.

    root root 750 /opt/ttakdap
    root root 750 /opt/ttakdap/config
    root root 700 /opt/ttakdap/secrets

    이후 일반 계정으로 cd /opt/ttakdap을 입력했을 때 Permission denied가 나오는 것은 의도한 결과다. 이 글에서는 Compose 파일의 절대 경로를 명령에 적어 두므로 운영 폴더로 직접 이동할 필요가 없다.

    4. 사이트 주소와 기본 환경 설정 만들기

    사용할 도메인을 입력하고 같은 블록 안에서 형식을 확인한 뒤 .env까지 만든다. https://나 마지막 /는 붙이지 않는다.

    [SSH로 Ubuntu에서 실행]

    read -rp "사용할 도메인(예: example.com): " SITE_DOMAIN
    
    if [[ "$SITE_DOMAIN" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]]; then
      printf '입력한 도메인: %s\n' "$SITE_DOMAIN"
    
      printf '%s\n' \
        'COMPOSE_PROJECT_NAME=ttakdap' \
        "SITE_URL=https://${SITE_DOMAIN}" \
        'TZ=Asia/Seoul' \
        'WORDPRESS_TABLE_PREFIX=wp_' |
      sudo tee /opt/ttakdap/.env >/dev/null
    
      sudo chmod 0600 /opt/ttakdap/.env
      sudo chown root:root /opt/ttakdap/.env
      sudo grep -E '^(SITE_URL|TZ)=' /opt/ttakdap/.env
      sudo stat -c '%U %G %a %n' /opt/ttakdap/.env
    else
      echo "도메인 형식이 맞지 않아 .env를 만들지 않았습니다."
    fi

    SITE_URL=https://내도메인과 root root 600이 보이면 된다. 이 파일에는 사이트 주소처럼 공개돼도 되는 설정만 넣고 DB 비밀번호는 넣지 않는다.

    오류 문장이 나오거나 도메인 철자가 틀렸다면 컨테이너를 시작하기 전에 코드 블록 전체를 다시 실행한다. WordPress를 초기화한 뒤 주소를 바꾸는 작업은 더 복잡해질 수 있으므로 지금 확인하는 편이 낫다.

    5. DB 비밀번호를 파일로 만들기

    MariaDB 관리자 비밀번호와 WordPress용 DB 비밀번호를 무작위로 만든다. 다음 명령은 파일에 값이 이미 있으면 새 값으로 덮어쓰지 않는다.

    [SSH로 Ubuntu에서 실행]

    sudo test -s /opt/ttakdap/secrets/db_password.txt ||
    sudo sh -c '
      umask 077
      openssl rand -hex 32 > /opt/ttakdap/secrets/db_password.txt
    '
    
    sudo test -s /opt/ttakdap/secrets/db_root_password.txt ||
    sudo sh -c '
      umask 077
      openssl rand -hex 32 > /opt/ttakdap/secrets/db_root_password.txt
    '

    WordPress 컨테이너가 일반 웹 사용자 권한으로 첫 번째 파일을 읽을 수 있게 하고, MariaDB 관리자용 파일은 root만 읽게 한다.

    sudo chown root:33 /opt/ttakdap/secrets/db_password.txt
    sudo chmod 0440 /opt/ttakdap/secrets/db_password.txt
    
    sudo chown root:root /opt/ttakdap/secrets/db_root_password.txt
    sudo chmod 0600 /opt/ttakdap/secrets/db_root_password.txt

    비밀번호 내용은 출력하지 않고 파일 크기와 권한만 확인한다.

    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까지 한 번에 복사한다.

    MariaDB 설정

    [SSH로 Ubuntu에서 실행]

    sudo tee /opt/ttakdap/config/mariadb.cnf >/dev/null <<'EOF'
    [mariadb]
    character-set-server = utf8mb4
    collation-server = utf8mb4_unicode_ci
    skip-name-resolve = 1
    max-connections = 100
    innodb-buffer-pool-size = 1G
    
    [client]
    default-character-set = utf8mb4
    EOF
    
    sudo chmod 0644 /opt/ttakdap/config/mariadb.cnf

    utf8mb4는 한글뿐 아니라 이모지까지 저장하기 위한 문자 설정이다. DB 캐시에는 1GB를 배정했다. 이는 1편의 메모리 8GB VM 기준이며, 메모리를 더 적게 할당했다면 그대로 복사하지 말고 낮춰야 한다.

    PHP 설정

    sudo tee /opt/ttakdap/config/php.ini >/dev/null <<'EOF'
    memory_limit = 256M
    upload_max_filesize = 64M
    post_max_size = 64M
    max_execution_time = 120
    max_input_time = 120
    max_input_vars = 3000
    expose_php = Off
    display_errors = Off
    log_errors = On
    error_log = /proc/self/fd/2
    session.cookie_httponly = 1
    session.cookie_secure = 1
    session.use_strict_mode = 1
    opcache.enable = 1
    opcache.memory_consumption = 128
    opcache.interned_strings_buffer = 16
    opcache.max_accelerated_files = 10000
    opcache.revalidate_freq = 60
    EOF
    
    sudo chmod 0644 /opt/ttakdap/config/php.ini

    사진 업로드 한도는 64MB로 두고, PHP 오류는 방문자 화면 대신 컨테이너 로그에 남기게 했다. 세션 쿠키는 다음 편에서 연결할 HTTPS를 기준으로 설정했다.

    두 파일이 비어 있지 않은지 확인한다.

    sudo test -s /opt/ttakdap/config/mariadb.cnf &&
    echo "MariaDB 설정 파일 확인"
    
    sudo test -s /opt/ttakdap/config/php.ini &&
    echo "PHP 설정 파일 확인"

    7. 완성된 compose.yaml 붙여넣기

    이 파일은 MariaDB와 WordPress 두 서비스를 정의한다. Cloudflare Tunnel은 3편에서 추가하므로 지금은 들어 있지 않다. 아래 블록도 첫 줄부터 마지막 EOF까지 한 번에 복사한다.

    [SSH로 Ubuntu에서 실행]

    sudo tee /opt/ttakdap/compose.yaml >/dev/null <<'EOF'
    name: ttakdap
    
    services:
      db:
        image: mariadb:11.4.13
        restart: unless-stopped
        environment:
          MARIADB_DATABASE: wordpress
          MARIADB_USER: wordpress
          MARIADB_PASSWORD_FILE: /run/secrets/db_password
          MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
          TZ: ${TZ:-Asia/Seoul}
        secrets:
          - db_password
          - db_root_password
        volumes:
          - mariadb_data:/var/lib/mysql
          - ./config/mariadb.cnf:/etc/mysql/conf.d/zz-ttakdap.cnf:ro
        networks:
          - db_internal
        healthcheck:
          test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
          interval: 10s
          timeout: 5s
          retries: 12
          start_period: 40s
        stop_grace_period: 1m
        mem_limit: 2g
        pids_limit: 200
        security_opt:
          - no-new-privileges:true
        logging:
          driver: json-file
          options:
            max-size: 10m
            max-file: "5"
    
      wordpress:
        image: wordpress:7.1.0-php8.4-apache
        restart: unless-stopped
        environment:
          WORDPRESS_DB_HOST: db:3306
          WORDPRESS_DB_USER: wordpress
          WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
          WORDPRESS_DB_NAME: wordpress
          WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
          WORDPRESS_CONFIG_EXTRA: |
            define( 'WP_HOME', '${SITE_URL:?SITE_URL is required in .env}' );
            define( 'WP_SITEURL', '${SITE_URL:?SITE_URL is required in .env}' );
            define( 'FORCE_SSL_ADMIN', true );
            define( 'DISALLOW_FILE_EDIT', true );
            define( 'WP_AUTO_UPDATE_CORE', 'minor' );
            define( 'WP_POST_REVISIONS', 20 );
            define( 'WP_ENVIRONMENT_TYPE', 'production' );
            define( 'WP_DEBUG_DISPLAY', false );
            @ini_set( 'display_errors', 0 );
          TZ: ${TZ:-Asia/Seoul}
        secrets:
          - db_password
        volumes:
          - wordpress_data:/var/www/html
          - ./config/php.ini:/usr/local/etc/php/conf.d/zz-ttakdap.ini:ro
        networks:
          - db_internal
          - edge
        depends_on:
          db:
            condition: service_healthy
        healthcheck:
          test:
            - CMD-SHELL
            - >-
              php -r '$$socket = @fsockopen("127.0.0.1", 80);
              exit($$socket ? 0 : 1);'
          interval: 15s
          timeout: 5s
          retries: 12
          start_period: 60s
        stop_grace_period: 30s
        mem_limit: 2g
        pids_limit: 300
        security_opt:
          - no-new-privileges:true
        logging:
          driver: json-file
          options:
            max-size: 10m
            max-file: "5"
    
    networks:
      db_internal:
        internal: true
      edge:
    
    volumes:
      mariadb_data:
      wordpress_data:
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt
      db_root_password:
        file: ./secrets/db_root_password.txt
    EOF
    
    sudo chmod 0640 /opt/ttakdap/compose.yaml
    sudo chown root:root /opt/ttakdap/compose.yaml

    긴 파일에서 꼭 이해해야 할 부분은 다음과 같다.

    설정의미
    depends_on과 DB healthcheckMariaDB가 연결 가능한 상태가 된 뒤 WordPress를 시작
    secrets두 컨테이너가 필요한 비밀번호 파일만 읽게 함
    mariadb_data, wordpress_data컨테이너를 다시 만들어도 DB와 사이트 파일을 별도 볼륨에 유지
    db_internalMariaDB를 Docker 내부 전용 네트워크에만 연결
    edgeWordPress의 외부 통신과 3편에서 추가할 Tunnel 연결에 사용
    ports 항목 없음WordPress 80번과 DB 3306번을 Ubuntu 호스트에 공개하지 않음
    WORDPRESS_CONFIG_EXTRAHTTPS 사이트 주소, 관리자 파일 편집 차단, 소규모 자동 업데이트와 글 개정본 수를 설정
    mem_limit, pids_limit8GB VM에서 한 컨테이너가 메모리나 프로세스를 끝없이 차지하지 않게 상한 설정
    no-new-privileges, logging추가 권한 획득을 막고 로그 파일을 10MB씩 최대 5개로 순환

    이미지 태그는 이 서버를 검토한 2026년 9월 1일의 기준으로 WordPress 7.1.0과 MariaDB 11.4.13을 고정했다. 무조건 영구적인 최신 권장 버전이라는 뜻은 아니다. 나중에 업데이트할 때는 먼저 백업하고 새 버전의 변경 사항을 확인한다.

    8. Compose 문법을 검사하고 컨테이너 시작하기

    파일을 저장했다고 바로 실행하지 않는다. 먼저 Compose의 YAML 문법과 필수 환경변수, 파일 참조 형식이 맞는지 확인한다. 이 검사는 secret 파일의 실제 내용이나 읽기 권한까지 보장하지 않으므로, 그 부분은 앞에서 test -s와 stat로 따로 확인했다.

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      config --quiet &&
    echo "Compose 문법 확인 완료"

    정상 결과는 다음 한 줄이다.

    Compose 문법 확인 완료

    줄 번호와 함께 오류가 나오면 그 위치의 들여쓰기를 먼저 확인한다. YAML은 탭이 아니라 공백을 사용하고, services:, db:, image:처럼 단계가 내려갈 때마다 공백 두 칸씩 들여쓴다.

    사용할 이미지를 먼저 내려받는다.

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      pull

    처음에는 이미지 크기만큼 내려받으므로 시간이 걸릴 수 있다. 완료되면 MariaDB를 먼저 시작한다.

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      up -d --wait db

    MariaDB의 첫 실행에서는 빈 볼륨에 DB와 계정을 만들기 때문에 다음 실행보다 오래 걸릴 수 있다. --wait는 단순히 프로세스가 켜진 순간이 아니라 Compose에 정의한 상태 검사가 통과할 때까지 기다린다.

    이어서 WordPress를 시작한다.

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      up -d --wait wordpress

    정상이라면 두 명령 모두 컨테이너가 Healthy 상태가 됐다는 내용으로 끝난다. 오류가 나오면 같은 명령을 무작정 반복하지 말고 아래 상태 확인부터 한다.

    9. 세 단계로 상태 직접 확인하기

    첫째, 두 컨테이너의 전체 상태

    [SSH로 Ubuntu에서 실행]

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      ps

    확인할 것은 다음 세 가지다.

    • db와 wordpress가 둘 다 목록에 있다.
    • 두 서비스의 상태에 healthy가 표시된다.
    • PORTS에 호스트 주소와 ->로 이어지는 매핑이 없다.

    공식 이미지가 내부에서 사용하는 80/tcp나 3306/tcp만 표시될 수 있다. 이것만으로는 호스트에 공개된 것이 아니다. 0.0.0.0:8080->80/tcp나 [::]:8080->80/tcp처럼 호스트 주소와 화살표가 나타날 때 포트가 게시된 것이다.

    둘째, MariaDB 초기화와 접속 준비

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      exec -T db \
      healthcheck.sh --connect --innodb_initialized &&
    echo "MariaDB 준비 완료"

    여기의 healthcheck.sh는 독자가 따로 만들거나 내려받는 파일이 아니다. MariaDB 공식 이미지 안에 기본으로 들어 있는 상태 확인 도구다. --connect는 DB 접속 가능 여부를, --innodb_initialized는 InnoDB 초기화가 끝났는지를 확인한다. 마지막에 MariaDB 준비 완료가 나오면 두 검사가 모두 통과한 것이다.

    셋째, WordPress가 비밀번호를 읽고 실제 DB에 로그인하는지 확인

    먼저 WordPress 실행 사용자가 secret 파일을 읽을 수 있는지 검사한다.

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      exec -T -u www-data wordpress \
      test -r /run/secrets/db_password &&
    echo "WordPress secret 읽기 확인"

    이제 같은 비밀번호로 wordpress DB에 실제 로그인한다. 비밀번호 값 자체는 화면에 출력하지 않는다.

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      exec -T -u www-data wordpress php -r '
    mysqli_report(MYSQLI_REPORT_OFF);
    $password = rtrim(
        file_get_contents(getenv("WORDPRESS_DB_PASSWORD_FILE")),
        "\r\n"
    );
    $db = @new mysqli("db", "wordpress", $password, "wordpress", 3306);
    if ($db->connect_errno) {
        fwrite(STDERR, "WordPress DB 로그인 실패\n");
        exit(1);
    }
    echo "WordPress DB 로그인 확인\n";
    '

    마지막 문장이 WordPress DB 로그인 확인이면 WordPress 컨테이너에서 MariaDB까지 실제 인증이 된 것이다.

    Apache가 컨테이너 내부의 80번 포트에서 연결을 받는지도 확인한다.

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      exec -T wordpress php -r '
    $socket = @fsockopen("127.0.0.1", 80, $errno, $error, 5);
    if (!$socket) {
        fwrite(STDERR, "$error ($errno)\n");
        exit(1);
    }
    fclose($socket);
    echo "WordPress 내부 80번 포트 연결 확인\n";
    '

    이 검사는 웹페이지의 내용이나 HTTP 상태 코드를 확인하는 것이 아니다. Apache 프로세스가 컨테이너 내부 80번 포트에서 연결을 받는지만 검사한다.

    MariaDB 준비부터 Apache TCP 포트 연결까지 이어지는 내부 상태 검사 순서

    그림 1. running 표시에서 멈추지 않고 secret 읽기, 실제 DB 인증, Apache 내부 포트 연결을 차례로 확인했다. 실제 터미널 화면이 아니라 검사 흐름을 개인정보 없이 재구성한 그림이다.

    이 네 검사를 첫 설치에서 한 번씩 직접 실행해 두면, 나중에 문제가 생겼을 때 DB 준비·파일 권한·DB 인증·웹서버 중 어디에서 막혔는지 구분하기 쉽다.

    10. 데이터 볼륨과 공개 포트 확인하기

    데이터가 저장될 Docker 볼륨을 확인한다.

    [SSH로 Ubuntu에서 실행]

    sudo docker volume ls --filter name=ttakdap

    이름 끝에 mariadb_data와 wordpress_data가 붙은 볼륨 두 개가 보여야 한다. 컨테이너를 다시 만들더라도 이 볼륨은 별도로 유지된다.

    호스트에 공개된 컨테이너 포트가 없는지도 한 번 더 확인한다.

    sudo docker ps \
      --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'

    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는 켜지지 않는다. 허용 규칙 중 하나라도 실패하면 && 뒤의 활성화 명령도 실행되지 않는다.

    정상 상태는 다음과 같다.

    • 상태가 active
    • 기본 incoming 정책이 deny
    • 기본 outgoing 정책이 allow
    • 22번 포트는 방금 확인한 Mac 주소 /32에서만 허용
    • 80·443·3306 허용 규칙은 없음

    현재 SSH 연결을 닫지 말고 새 Mac 터미널을 연다.

    [새 Mac 터미널에서 실행]

    read -rp "Ubuntu 사용자명: " ADMIN_USER
    read -rp "Ubuntu VM 주소: " VM_IP
    
    ssh -i "$HOME/.ssh/ttakdap_vm" \
      -o IdentitiesOnly=yes \
      -o BatchMode=yes \
      "$ADMIN_USER@$VM_IP" \
      'uname -s; id -un'

    Linux와 Ubuntu 사용자명이 나오면 새 연결이 성공한 것이다. 그때만 다음 절로 넘어간다. 실패하면 Parallels 콘솔에서 sudo ufw status numbered로 규칙부터 확인한다.

    12. SSH 비밀번호 로그인을 끄기

    1편에서 등록한 공개키가 실제로 남아 있는지 먼저 확인한다.

    [SSH로 Ubuntu에서 실행]

    test -s "$HOME/.ssh/authorized_keys" &&
    echo "SSH 공개키 파일 확인 완료"

    확인 문장이 나오지 않으면 절대 비밀번호 로그인을 끄지 않는다. 공개키 파일이 확인됐다면 별도 설정 파일을 만든다.

    sudo tee /etc/ssh/sshd_config.d/00-ttakdap-hardening.conf >/dev/null <<'EOF'
    PubkeyAuthentication yes
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    ChallengeResponseAuthentication no
    PermitRootLogin no
    AuthenticationMethods publickey
    EOF
    
    sudo chmod 0600 /etc/ssh/sshd_config.d/00-ttakdap-hardening.conf
    sudo /usr/sbin/sshd -t &&
    sudo systemctl reload ssh

    sshd -t는 설정 문법을 검사한다. 오류가 나면 SSH 서비스를 다시 읽지 않으므로, 출력된 줄을 고치기 전에는 현재 SSH 창을 닫지 않는다.

    적용 결과를 확인한다.

    sudo /usr/sbin/sshd -T |
    grep -E \
    '^(pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|permitrootlogin|authenticationmethods) '

    중요한 값은 다음과 같다.

    pubkeyauthentication yes
    passwordauthentication no
    kbdinteractiveauthentication no
    permitrootlogin no
    authenticationmethods publickey

    다시 새 Mac 터미널에서 키 접속을 확인한다.

    [새 Mac 터미널에서 실행]

    read -rp "Ubuntu 사용자명: " ADMIN_USER
    read -rp "Ubuntu VM 주소: " VM_IP
    
    ssh -i "$HOME/.ssh/ttakdap_vm" \
      -o IdentitiesOnly=yes \
      -o BatchMode=yes \
      "$ADMIN_USER@$VM_IP" \
      'uname -s; id -un'

    Linux와 Ubuntu 사용자명이 다시 나오면 키 전용 로그인이 적용된 것이다. 새 접속이 성공하기 전에는 기존 SSH 창이나 Parallels 콘솔을 닫지 않는다.

    13. 브라우저에서 바로 안 열려도 정상이다

    이번 단계에서는 브라우저에 다음 주소를 입력해도 WordPress 설치 화면이 나오지 않는다.

    http://<VM_IP>

    오류가 아니라 의도한 구성이다. compose.yaml에 ports 항목이 없기 때문에 WordPress 컨테이너의 80번 포트가 Ubuntu 호스트에 연결되지 않았다.

    2편의 완료 기준은 브라우저 화면이 아니라 다음 세 가지다.

    1. db와 wordpress가 모두 healthy다.
    2. WordPress에서 실제 DB 로그인과 내부 80번 포트 검사가 통과한다.
    3. 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 출력을 확인한다.

    Compose 문법 오류가 나온다

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      config

    오류가 가리키는 줄 주변의 공백 들여쓰기와 따옴표를 확인한다. 이 명령의 전체 출력에는 사이트 주소 같은 설정이 포함될 수 있으므로 그대로 공개 게시판에 붙이지 않는다.

    db나 wordpress가 healthy가 되지 않는다

    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      ps
    
    sudo docker compose \
      --env-file /opt/ttakdap/.env \
      -f /opt/ttakdap/compose.yaml \
      logs --tail 100 db wordpress

    로그에서 먼저 발생한 오류부터 본다. 외부에 공유할 때는 사용자명, 호스트명, 도메인, 내부 주소를 제거한다.

    Access denied가 보인다

    secret 파일을 삭제하지 말고 권한과 볼륨부터 확인한다.

    sudo stat -c '%U %G %a %s %n' \
      /opt/ttakdap/secrets/db_password.txt \
      /opt/ttakdap/secrets/db_root_password.txt
    
    sudo docker volume ls --filter name=ttakdap

    기존 MariaDB 볼륨이 있는데 비밀번호 파일만 새로 만들어졌다면 두 값이 서로 맞지 않을 수 있다. 이때 새 secret을 계속 만들지 말고 설치 전 스냅샷이나 별도 DB 복구 절차를 사용한다.

    No space left on device가 보인다

    df -h /
    sudo du -sh /var/lib/docker

    무엇이 공간을 쓰는지 확인하기 전에 Docker 볼륨이나 /var/lib/docker를 지우지 않는다.

    방화벽 적용 뒤 새 SSH 접속이 안 된다

    Parallels의 Ubuntu 콘솔에서 실행한다.

    [Ubuntu VM 콘솔에서 실행]

    sudo ufw status numbered
    ip address

    기존 SSH 창이 남아 있다면 닫지 않는다. Mac의 실제 Shared Network 주소를 다시 확인하고 올바른 허용 규칙을 먼저 추가한 뒤 새 접속을 시험한다.

    2편 완료 점검표

    • ☐ 현재 명령을 Mac과 Ubuntu 중 어디에서 실행하는지 구분했다.
    • ☐ 설치 전 Parallels 스냅샷을 만들었다.
    • ☐ Docker Engine과 Compose 플러그인이 설치됐다.
    • ☐ /opt/ttakdap에 .env, 설정 파일, compose.yaml을 직접 만들었다.
    • ☐ DB 비밀번호 두 개를 별도 파일로 만들고 내용은 화면에 출력하지 않았다.
    • ☐ docker compose config --quiet가 통과했다.
    • ☐ db와 wordpress가 모두 healthy다.
    • ☐ WordPress 컨테이너에서 실제 DB 로그인이 됐다.
    • ☐ docker port 검사 결과가 호스트 공개 포트 없음이다.
    • ☐ mariadb_data와 wordpress_data 볼륨이 만들어졌다.
    • ☐ UFW는 현재 Mac 주소의 SSH만 허용한다.
    • ☐ SSH 키 전용 로그인으로 새 연결이 된다.
    • ☐ docker compose down -v를 실행하지 않았다.
    • ☐ 아직 Cloudflare Tunnel과 WordPress 초기 설정은 하지 않았다.

    다음 글에서 할 일

    3편에서는 외부에 포트를 열지 않은 현재 상태에서 Cloudflare Tunnel을 연결한다.

    1. Cloudflare에서 Named Tunnel 만들기
    2. WordPress 초기 설치 화면을 임시 Access로 보호하기
    3. Tunnel 토큰을 Ubuntu의 권한 제한 파일로 저장하기
    4. Compose에 cloudflared 서비스를 추가하기
    5. 도메인을 wordpress:80에 연결하기
    6. WordPress 초기 설정 마치기
    7. 공개 사이트와 관리자 경로의 보호 범위 확인하기

    다음 글: Cloudflare Tunnel로 WordPress 공개하기

    공식 참고 자료

  • 맥미니에 Ubuntu 서버 설치하기: Parallels VM을 처음부터 만드는 방법

    맥미니에 Ubuntu 서버 설치하기: Parallels VM을 처음부터 만드는 방법

    맥미니에 WordPress 서버를 만들면서 처음 헷갈린 것은 Linux 명령어가 아니었다. 지금 보고 있는 검은 화면이 Mac 터미널인지, Ubuntu 가상머신 화면인지 구분하는 일이 더 어려웠다.

    같은 cd나 sudo도 어느 화면에서 실행하느냐에 따라 전혀 다른 컴퓨터에 적용된다. 비밀번호를 입력해도 화면에 글자나 별표가 나타나지 않아, 입력이 안 되는 줄 알고 기다리기도 쉽다.

    이번 글에서는 WordPress를 설치하기 전 단계까지만 진행한다. 끝까지 따라 하면 다음 상태가 된다.

    • 맥미니 안에서 Ubuntu Server 가상머신이 실행된다.
    • Ubuntu가 ARM64로 설치됐는지 직접 확인할 수 있다.
    • Shared Network(NAT)를 통해 VM이 인터넷에 연결된다.
    • Mac에서 Ubuntu로 SSH 접속할 수 있다.
    • 비밀번호 대신 전용 SSH 키로 접속할 수 있다.
    • 공유기 포트포워딩은 만들지 않는다.

    Docker와 WordPress는 2편에서 설치한다. 한 번에 다 진행하지 않는 이유는 간단하다. Ubuntu 설치와 SSH가 확실히 되는 상태를 먼저 만들어야, 다음 단계에서 문제가 생겨도 원인을 좁힐 수 있기 때문이다.

    이 글은 M1·M2·M3·M4 같은 Apple Silicon Mac을 기준으로 한다. Intel Mac은 ISO 종류부터 다르므로 그대로 따라 하면 안 된다.

    홈서버 설치 시리즈

    1. 이번 글: Parallels에 Ubuntu Server VM 만들기
    2. 다음 글: Ubuntu에 Docker로 WordPress·MariaDB 설치하기
    3. 이어지는 글: Cloudflare Tunnel로 공개하고 관리자 경로 보호하기
    4. 마지막 글: WP-Cron을 systemd로 바꾸고 Cloudflare R2에 백업하기
    맥미니 WordPress 홈서버 4편 설치 순서

    그림 1. Ubuntu VM부터 백업 검증까지 한꺼번에 진행하지 않고, 각 단계의 성공을 확인한 뒤 다음 편으로 넘어간다.

    전체 구조와 장단점부터 보고 싶다면 맥미니를 24시간 홈서버로 써도 될까?를 먼저 읽으면 된다.

    먼저 구분할 것: 터미널은 두 개다

    설치를 시작하기 전에 아래 표를 한 번 보고 가는 게 좋다.

    글의 표시실제 실행 장소주로 하는 일
    Mac에서 실행macOS의 터미널 또는 Codex 터미널ISO 확인, SSH 접속, 키 생성
    Ubuntu VM에서 실행Parallels 창 안의 Ubuntu 콘솔Ubuntu 상태 확인, Linux 설정
    SSH로 Ubuntu에서 실행Mac 터미널에서 Ubuntu에 접속한 화면화면은 Mac에 있지만 명령은 Ubuntu에서 실행

    프롬프트 모양만 보고 판단하면 틀릴 수 있다. 헷갈릴 때는 아래 명령부터 실행한다.

    uname -s
    pwd

    대체로 다음처럼 구분된다.

    결과현재 위치
    Darwin, /Users/...Mac
    Linux, /home/...Ubuntu

    앞으로 나오는 <VM_IP>와 <ADMIN_USER>는 예시가 아니라 바꿔 넣을 자리다. 실제 명령에서는 자신이 확인한 값으로 바꾸고 꺾쇠괄호도 제거해야 한다.

    준비물

    • Apple Silicon 맥미니
    • 현재 버전의 Parallels Desktop
    • Ubuntu Server 24.04.4 LTS ARM64 ISO
    • 안정적인 인터넷 연결
    • Ubuntu 계정에 사용할 사용자명과 긴 비밀번호
    • VM 파일을 보관할 Mac의 로컬 저장 공간

    이번에 만든 VM의 설정은 다음과 같다.

    항목이번 구성값
    CPU4 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 공식 릴리스 페이지에서 다음 파일을 받는다.

    ubuntu-24.04.4-live-server-arm64.iso

    파일명에서 확인할 부분은 server와 arm64다.

    • server: 그래픽 데스크톱이 기본 포함되지 않은 서버 설치 이미지
    • arm64: Apple Silicon에서 사용할 ARM64 이미지

    amd64, Raspberry Pi용 사전 설치 이미지, arm64+largemem 파일과 혼동하지 않는다. 이번 구성은 이름 그대로 끝나는 일반 live-server-arm64.iso를 사용했다.

    내려받은 파일의 SHA-256 확인

    Ubuntu 다운로드 폴더에는 ISO와 함께 SHA256SUMS 파일이 제공된다. ISO가 전송 중 손상되지 않았는지 확인할 수 있다.

    SHA256SUMS도 같은 공식 릴리스 페이지에서 내려받은 뒤 두 파일을 Mac의 다운로드 폴더에 둔다.

    [Mac에서 실행]

    cd "$HOME/Downloads"
    
    shasum -a 256 ubuntu-24.04.4-live-server-arm64.iso
    grep 'ubuntu-24.04.4-live-server-arm64.iso' SHA256SUMS

    두 줄 앞부분의 64자리 값이 같아야 한다. 다르면 ISO를 사용하지 말고 공식 페이지에서 다시 받는다.

    이 비교는 파일 손상 여부를 확인하는 기본 절차다. 출처 확인까지 엄격하게 하려면 Ubuntu가 함께 제공하는 SHA256SUMS.gpg 서명 검증 절차가 추가로 필요하다.

    3. Parallels에서 새 VM 만들기

    Parallels Desktop을 열고 Control Center에서 새 가상머신 만들기를 시작한다.

    1. 오른쪽 위의 + 버튼을 누른다.
    2. Install Windows, Linux, or macOS from an image file을 선택한다.
    3. Continue를 누르고 앞에서 받은 ARM64 ISO를 지정한다.
    4. 설치 전 설정을 변경하는 항목을 켠다.
    5. VM 이름과 저장 위치를 정한 뒤 생성한다.

    Parallels 버전과 표시 언어에 따라 버튼 이름이나 위치가 조금 다를 수 있다. ISO를 자동으로 찾지 못하면 수동 선택 기능을 사용해도 된다. 이때 파일명 끝이 arm64.iso인지 다시 확인한다.

    VM 이름은 역할을 알아보기 쉽게 ttakdap-ubuntu로 정했다. VM 파일은 iCloud Drive나 다른 동기화 폴더가 아니라 Mac의 로컬 저장 공간에 뒀다.

    예시는 다음과 같다.

    ~/Parallels

    VM은 실행 중 계속 바뀌는 큰 가상 디스크 파일이다. 운영용 VM을 동기화 폴더 밖에 두면 동기화 충돌과 불필요한 업로드를 피하기 쉽다.

    4. CPU·메모리·디스크 설정하기

    Ubuntu 설치를 시작하기 전에 VM 설정을 연다. 이미 켜져 있다면 일시 정지가 아니라 완전 종료한 뒤 바꾼다.

    CPU와 메모리

    이번 구성은 4 vCPU와 메모리 8GB를 할당했다. Parallels 에디션 비교표에 표시된 Standard Edition의 VM당 상한에도 맞는 값이다.

    Mac 전체 메모리가 8GB인데 VM에 8GB를 모두 주면 안 된다. macOS가 사용할 몫도 남겨야 한다. Parallels가 보여주는 권장 범위를 벗어나 무작정 많이 할당한다고 항상 빨라지는 것도 아니다.

    현재 공식 안내의 설정 경로는 다음과 같다.

    Actions → Configure → Hardware → CPU & Memory

    가상 디스크

    가상 디스크 상한은 64GB로 두고 확장형 디스크를 사용했다. 확장형은 VM을 만들자마자 Mac 저장 공간 64GB를 전부 차지하는 방식은 아니며, Ubuntu에서 사용하는 양에 따라 파일이 커진다.

    하지만 상한이 여유 공간을 만들어 주는 것은 아니다. WordPress 사진과 백업이 늘면 Mac과 VM 양쪽의 실제 사용량을 계속 확인해야 한다.

    네트워크

    네트워크는 Parallels의 기본 NAT 방식인 Shared Network를 선택했다.

    Actions → Configure → Hardware → Network → Shared Network

    Shared Network에서는 VM이 Mac의 연결을 공유해 인터넷으로 나갈 수 있다. 반대로 외부 기기가 VM으로 직접 들어오게 하려면 별도 포트포워딩이 필요하다.

    이번 서버는 뒤에서 Cloudflare Tunnel을 사용하므로 다음 설정을 만들지 않는다.

    • 공유기 80·443 포트포워딩
    • Parallels VM 포트포워딩
    • 브리지 네트워크

    공인 고정 IP도 필요하지 않다. Tunnel 연결은 Ubuntu 쪽에서 먼저 외부로 나가기 때문이다.

    Mac과 VM의 공유 기능

    초기 작업이 끝난 뒤에는 다음 기능을 끈다.

    • Mac 공유 폴더와 공유 프로필
    • Mac 볼륨 자동 매핑
    • 클립보드 동기화

    공유 기능을 켜면 VM에서 Mac 파일이나 클립보드 내용에 접근할 수 있다. 공개 서버용 VM에 꼭 필요한 기능이 아니므로 공격면을 줄이기 위한 선택이다. 이는 Parallels의 필수 설정이 아니라 이번 서버의 운영 방침이다.

    Parallels에서 실제 WordPress Ubuntu VM의 CPU·메모리·디스크·공유 네트워크 설정을 확인한 화면

    그림 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를 추측해서 넣지 않는다.

    Mac에서 실제 Ubuntu VM의 버전, 아키텍처, 자원과 SSH 상태를 확인한 결과

    그림 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 화면을 비교한다.

    [Ubuntu VM에서 실행]

    sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

    일치하면 Mac의 질문에 yes를 입력한다. 이어서 Ubuntu 사용자 비밀번호를 입력한다. 여기에서도 입력 문자는 보이지 않는다.

    접속한 다음 위치를 확인한다.

    uname -s
    pwd

    결과가 Linux와 /home/<ADMIN_USER> 형태라면 Mac 터미널을 통해 Ubuntu에 들어온 것이다.

    접속을 끝낼 때는 다음을 입력한다.

    exit

    9. 전용 SSH 키 만들고 등록하기

    다음 글에서 설치 명령을 안전하게 실행하려면 전용 SSH 키를 먼저 준비하는 편이 좋다. 개인키는 Mac에만 두고, Ubuntu에는 .pub으로 끝나는 공개키만 등록한다.

    같은 이름의 키가 있는지 먼저 확인

    [Mac에서 실행]

    ls -l "$HOME/.ssh/ttakdap_vm" "$HOME/.ssh/ttakdap_vm.pub"

    두 파일이 이미 있다면 덮어쓰지 않는다. 기존 키가 어떤 용도인지 확인한 뒤 다른 파일명을 사용한다.

    파일이 없다는 메시지가 나올 때만 새 키를 만든다.

    ssh-keygen -t ed25519 \
      -f "$HOME/.ssh/ttakdap_vm" \
      -C "mac-mini-wordpress-vm"

    키 암호를 설정하면 개인키가 유출됐을 때 한 겹 더 보호할 수 있다. 다만 아래의 비대화형 접속 시험을 하려면 먼저 키를 macOS의 SSH 에이전트에 등록해야 한다.

    ssh-add --apple-use-keychain "$HOME/.ssh/ttakdap_vm"

    사용 중인 macOS에서 --apple-use-keychain 옵션을 지원하지 않는다는 메시지가 나오면 ssh-add "$HOME/.ssh/ttakdap_vm"로 현재 로그인 세션에만 등록할 수 있다. 자동 운영에 사용할 키는 편의와 보안, Mac 계정과 디스크의 보호 상태를 함께 고려해 관리한다.

    공개키를 Ubuntu에 등록

    앞에서 만든 변수값이 같은 Mac 터미널에 남아 있는지 확인한다.

    printf 'user=%s\nvm=%s\n' "$ADMIN_USER" "$VM_IP"

    정확하면 공개키를 등록한다.

    ssh "$ADMIN_USER@$VM_IP" \
      'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys' \
      < "$HOME/.ssh/ttakdap_vm.pub"

    이 명령은 마지막으로 Ubuntu 비밀번호를 물을 수 있다. 개인키 내용은 VM이나 채팅에 붙여 넣지 않는다.

    Ubuntu 계정 비밀번호 대신 키로 접속되는지 확인

    새 Mac 터미널을 하나 더 열고 변수 두 개를 다시 설정한다. 그다음 아래 명령을 실행한다.

    ssh -i "$HOME/.ssh/ttakdap_vm" \
      -o IdentitiesOnly=yes \
      -o BatchMode=yes \
      "$ADMIN_USER@$VM_IP" \
      'id -un'

    정상이라면 자신이 만든 Ubuntu 사용자명이 출력된다. BatchMode=yes에서는 Ubuntu 계정 비밀번호 입력으로 넘어가지 않으므로, 실패하면 키 등록이나 SSH 에이전트 준비가 아직 끝난 것이 아니다.

    비밀번호 SSH 로그인을 끄는 작업은 지금 하지 않는다. Parallels 콘솔을 열어 둔 채 키 접속을 확실히 확인한 뒤, 2편의 방화벽·SSH 강화 단계에서 진행한다.

    Mac에서 BatchMode로 Ubuntu VM의 SSH 키 전용 접속 성공을 확인한 결과

    그림 4. BatchMode=yes로 비밀번호 입력을 허용하지 않은 상태에서 전용 SSH 키 접속이 성공했다. 사용자명·호스트명·VM 주소·키 내용은 화면에 넣지 않았다.

    10. 공유 기능을 끄고 VM 재부팅 확인하기

    SSH 키 접속까지 성공했다면 서버 운영에 쓰지 않을 공유 폴더·공유 프로필·클립보드 동기화를 Parallels 설정에서 끈다. 에디션에 따라 보안 격리 설정이 따로 보일 수 있으므로, 필요한 Mac·VM 연동 기능이 없는지 먼저 확인하고 적용한다.

    그다음 Ubuntu VM 안에서 한 번 재부팅한다.

    [Ubuntu VM 또는 SSH로 Ubuntu에서 실행]

    sudo reboot

    SSH 연결은 끊기는 것이 정상이다. Ubuntu 로그인 화면이 다시 나타난 뒤 Mac에서 키 접속 명령을 다시 실행해 로그인되는지 확인한다.

    이 시험은 Ubuntu VM 재부팅 확인일 뿐이다. 맥미니 전원을 껐다 켠 뒤 Parallels와 VM이 사용자 로그인 없이 살아나는지 확인한 정전 복구 시험은 아니다.

    1편 완료 점검표

    • ☐ Mac의 uname -m이 arm64다.
    • ☐ Ubuntu Server ARM64 ISO의 SHA-256이 공식 목록과 일치한다.
    • ☐ VM은 4 vCPU·8GB·64GB 확장형·Shared Network로 설정했다.
    • ☐ 설치 대상이 Parallels의 가상 디스크인지 확인했다.
    • ☐ 일반 Ubuntu Server와 OpenSSH Server를 설치했다.
    • ☐ Ubuntu의 VERSION_ID가 24.04다.
    • ☐ Ubuntu의 uname -m이 aarch64다.
    • ☐ CPU·메모리·디스크가 설정한 범위로 보인다.
    • ☐ 기본 네트워크 경로가 있고 SSH 서비스가 active다.
    • ☐ Mac에서 VM으로 SSH 접속된다.
    • ☐ BatchMode=yes로 전용 SSH 키 접속을 확인했다.
    • ☐ VM 재부팅 뒤에도 키 접속이 된다.
    • ☐ 공유기와 Parallels에 80·443 포트포워딩을 만들지 않았다.
    • ☐ 아직 Docker·WordPress·Cloudflare Tunnel은 설치하지 않았다.

    자주 막히는 문제

    비밀번호를 입력해도 아무것도 안 보인다

    Linux 터미널의 정상 동작이다. 별표도 표시되지 않는다. 비밀번호를 입력한 뒤 Enter를 누른다.

    정말 sudo 인증이 되는지 별도로 보고 싶다면 Ubuntu 콘솔에서 실행한다.

    sudo id -u

    정상 결과는 0이다.

    VM이 설치 화면으로 계속 돌아온다

    Ubuntu ISO가 가상 CD/DVD에 계속 연결돼 있는지 확인한다. VM을 완전히 끈 뒤 ISO 연결을 해제하고 다시 시작한다.

    uname -m이 x86_64로 나온다

    이 글의 Apple Silicon ARM64 구성과 다르다. 선택한 VM과 ISO 파일명을 다시 확인한다.

    SSH 접속이 거부된다

    Ubuntu 콘솔에서 아래 결과를 확인한다.

    systemctl is-active ssh
    hostname -I
    ip route
    • SSH가 active인가?
    • Mac에서 입력한 VM 주소가 현재 주소와 같은가?
    • Parallels 네트워크가 Shared Network인가?

    설치 중 OpenSSH Server를 선택하지 않았다면 Ubuntu 콘솔에서 인터넷 연결을 확인한 뒤 설치한다.

    sudo apt update
    sudo apt install openssh-server

    SSH 키 접속이 Permission denied로 끝난다

    Ubuntu 콘솔에서 권한을 바로잡는다.

    chmod 700 ~/.ssh
    chmod 600 ~/.ssh/authorized_keys

    그다음 Mac에서 BatchMode=yes 확인 명령을 다시 실행한다. 그래도 안 되면 공개키가 현재 Ubuntu 사용자의 authorized_keys에 들어갔는지 확인한다. 개인키를 VM으로 복사해서 해결하려고 하면 안 된다.

    CPU나 메모리 설정을 바꿀 수 없다

    VM이 실행 또는 일시 정지 상태일 수 있다. Ubuntu에서 정상 종료한다.

    sudo poweroff

    Parallels에서 VM이 완전히 꺼진 것을 확인한 뒤 설정을 다시 연다.

    Parallels Tools 설치 안내가 나온다

    Parallels Tools는 Linux VM에서 자동 설치되는 경우가 많다. SSH로 운영하는 WordPress 서버 자체는 화면 해상도·공유 폴더·클립보드 기능에 의존하지 않는다. 자동 설치가 안 됐더라도 Ubuntu와 SSH 확인부터 끝낼 수 있다.

    공유 기능이 꼭 필요하다면 Parallels의 Actions → Install Parallels Tools 안내를 따른다. 서버 운영 준비가 끝난 뒤에는 사용하지 않는 공유 기능을 다시 끈다.

    다음 글에서 할 일

    Ubuntu VM과 SSH 준비는 여기까지다. 2편에서는 현재 Mac에서 편집 중인 배포 파일, VM에 임시로 옮긴 파일, 실제 서비스가 실행되는 파일을 먼저 구분한다. 이 구분을 하지 않으면 ‘소스 디렉터리를 변경하라’는 메시지만 보고 비밀번호 입력을 기다리기 쉽다.

    다음 글의 목표는 다음과 같다.

    1. 비밀값이 제거된 공개용 설치 묶음 준비
    2. Mac에서 Ubuntu VM으로 파일 전달
    3. Mac의 실제 관리 주소 확인
    4. Docker Engine과 Compose 설치
    5. MariaDB와 WordPress 컨테이너 분리
    6. 파일 기반 DB secret 생성
    7. UFW와 SSH 키 전용 로그인 적용
    8. 외부 공개 전 내부 상태 검사

    다음 글: Ubuntu에 Docker로 WordPress 설치하기

    공식 참고 자료