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-cli | docker 명령 제공 |
containerd.io | 컨테이너 실행과 수명 주기 관리 |
docker-buildx-plugin | Docker 이미지 빌드 기능 |
docker-compose-plugin | compose.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 healthcheck | MariaDB가 연결 가능한 상태가 된 뒤 WordPress를 시작 |
secrets | 두 컨테이너가 필요한 비밀번호 파일만 읽게 함 |
mariadb_data, wordpress_data | 컨테이너를 다시 만들어도 DB와 사이트 파일을 별도 볼륨에 유지 |
db_internal | MariaDB를 Docker 내부 전용 네트워크에만 연결 |
edge | WordPress의 외부 통신과 3편에서 추가할 Tunnel 연결에 사용 |
ports 항목 없음 | WordPress 80번과 DB 3306번을 Ubuntu 호스트에 공개하지 않음 |
WORDPRESS_CONFIG_EXTRA | HTTPS 사이트 주소, 관리자 파일 편집 차단, 소규모 자동 업데이트와 글 개정본 수를 설정 |
mem_limit, pids_limit | 8GB 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번 포트에서 연결을 받는지만 검사한다.

그림 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편의 완료 기준은 브라우저 화면이 아니라 다음 세 가지다.
db와wordpress가 모두healthy다.- WordPress에서 실제 DB 로그인과 내부 80번 포트 검사가 통과한다.
docker port검사 결과가호스트 공개 포트 없음이다.
3편에서는 현재 구성을 유지한 채 Cloudflare Tunnel 컨테이너를 edge 네트워크에 추가한다. 공유기나 VM에 웹 포트를 열지 않고, 외부에서 들어온 HTTPS 요청을 Tunnel이 wordpress:80으로 전달하게 한다. WordPress 초기 설정 화면도 그때 연다.
문제가 생겼을 때 확인할 순서
docker: command not found가 나온다
Docker 패키지 설치가 끝나지 않은 것이다. 아래 두 명령부터 확인한다.
apt-cache policy docker-ce
sudo systemctl status docker --no-pager
docker-ce 후보 버전이 보이지 않으면 앞에서 만든 Docker 저장소 설정 파일과 apt update 출력을 확인한다.
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을 연결한다.
- Cloudflare에서 Named Tunnel 만들기
- WordPress 초기 설치 화면을 임시 Access로 보호하기
- Tunnel 토큰을 Ubuntu의 권한 제한 파일로 저장하기
- Compose에
cloudflared서비스를 추가하기 - 도메인을
wordpress:80에 연결하기 - WordPress 초기 설정 마치기
- 공개 사이트와 관리자 경로의 보호 범위 확인하기
다음 글: Cloudflare Tunnel로 WordPress 공개하기
공식 참고 자료
- Docker Engine을 Ubuntu에 설치하는 공식 절차
- Linux에서 Docker Compose 플러그인 설치하기
- Compose 파일 명세
- 컨테이너 시작 순서와 상태 검사
- Compose secret 사용법
- Docker 포트 공개 방식
- Docker와 UFW 주의점
- Ubuntu UFW 방화벽 안내
- WordPress Docker 공식 이미지
- MariaDB Docker 공식 이미지
- MariaDB
healthcheck.sh안내
