breathed.kr · 서버 구성 문서
도메인 세 개, 컨테이너 여섯 개가 서버 한 대 위에서 어떻게 나뉘어 도는지, 그리고 같은 것을 손으로 다시 만드는 방법까지 정리했습니다.
2026-09-15 기준 · breathed-home (211.227.66.46)
2026-09-15 개편 이후
| 주소 | 내용 | 스택 |
|---|---|---|
| breathed.kr | 랜딩 · 성경 타임라인 · 종교개혁 자료 | breathed |
| teams.breathed.kr | INSOO CMS 콘솔이 사이트 루트 | insoo |
| teams.breathed.kr/2026jecheon/ | 제천 프로젝트 | insoo |
| teams.breathed.kr/2026kyrgyzstan/ | 키르기스스탄 프로젝트 | insoo |
| insoo.breathed.kr/sermon/ | 설교문 | insoo |
| insoo.breathed.kr/_otl/container/ | 이 문서 — 서버 구성 해설 | insoo |
www.breathed.kr 로 들어오면 301 한 번으로 breathed.kr 에 도착합니다.
주소를 하나로 모으기 위해 80번 단계에서 이미 apex 로 보내기 때문에, 중간에 두 번 튕기지 않습니다.
앞으로 프로젝트가 늘어나면 teams/ 아래에 폴더를 만들고
관리자에서 공개 경로를 /2027xxx/board 형태로 지정하면 됩니다.
시작은 MySQL 하나 때문이었다
이관해 온 사이트는 MySQL 로 돌고 있었습니다. 서버에 직접 설치하려면 MySQL 이든 MariaDB 든 하나를 골라야 하고, 그 선택이 앞으로 올릴 모든 사이트에 적용됩니다. 나중에 다른 DB 를 쓰는 사이트를 옮겨오면 방법이 없습니다.
컨테이너는 운영체제를 복제하지 않습니다. 리눅스 커널은 이 서버 것을 빌려 쓰고 앱과 필요한 라이브러리만 격리합니다. 그 차이가 숫자로 드러납니다.
| 컨테이너 | 정체 | 메모리 |
|---|---|---|
| insoo-db-1 | MySQL 9.7 (CMS 데이터) | 548 MiB |
| breathed-db-1 | MySQL 9.7 (아직 비어 있음) | 446 MiB |
| insoo-php-1 | PHP 8.5 + ffmpeg | 19 MiB |
| breathed-php-1 | PHP 8.5 | 12 MiB |
| insoo-web-1 | nginx 1.28 | 6 MiB |
| breathed-web-1 | nginx 1.28 | 5 MiB |
| 합계 6개 | 전체 7.2 GiB 중 | 약 1.0 GiB |
가상머신 6대라면 각각 OS 를 올려야 하니 10 GB 이상은 각오해야 합니다. 대부분이 MySQL 두 개이고, nginx·PHP 컨테이너는 각각 20 MB 안쪽입니다. 이래서 “사이트마다 하나씩”이 현실적인 선택이 됩니다.
알아야 할 말 세 개
image
앱이 통째로 담긴 읽기 전용 설계도. 실행되지 않고 디스크에 있습니다. mysql:9.7 같은 이름이 이것입니다.
container
이미지로 실제로 켠 것. 지우고 다시 만들어도 이미지는 그대로라 언제든 같은 상태로 돌아갑니다.
volume
컨테이너 안 폴더를 서버의 실제 폴더에 연결. 컨테이너를 지워도 데이터가 남는 이유입니다.
이 서버에 실물 예가 있습니다. nginx:1.28-alpine 이미지 하나로
insoo-web-1 과 breathed-web-1 두 컨테이너를 켰습니다.
같은 설계도에 설정과 포트만 다르게 넣은 것입니다.
브라우저에서 화면까지
사이트가 컨테이너 안에 있으면 문제가 하나 생깁니다. 웹 포트는 서버에 하나뿐인데
도메인은 셋이라는 것. 그래서 누군가는 들어온 요청의 Host 를 보고 어디로 보낼지
정해줘야 합니다. 그 역할이 호스트 nginx 입니다.
Host 를 한 번 더 보고
teams 와 insoo 중 어느 문서 루트를 쓸지 정합니다.
URL 과 폴더가 일치하도록 맞춰 두었다
/home/sites/
├─ breathed/ # breathed.kr
│ ├─ compose.yaml .env # 스택 설계도 · 비밀값(0600)
│ ├─ public/ → /var/www/html
│ │ ├─ index.php # 랜딩
│ │ ├─ bible.php # 성경, 기록의 시간
│ │ └─ reformation.php
│ ├─ php/ nginx/ db/ logs/ backups/
│
└─ insoo/ # teams. + insoo. (한 스택이 둘 담당)
├─ compose.yaml .env
├─ app/ → /var/www
│ ├─ teams/ → /var/www/teams # teams.breathed.kr
│ │ ├─ index.php app.php lib/ admin/ member/ … # CMS 가 루트
│ │ ├─ uploads/ # 12G
│ │ ├─ 2026jecheon/
│ │ └─ 2026kyrgyzstan/
│ └─ insoo/ → /var/www/insoo # insoo.breathed.kr
│ ├─ index.php
│ ├─ sermon/
│ └─ _otl/container/ # 이 문서
├─ php/ nginx/ db/ logs/ backups/
└─ restore-from-backup.sh
왜 이렇게 맞췄나 — URL 과 폴더 이름이 같으면 머릿속에서 변환할 일이 없습니다.
teams.breathed.kr/2026jecheon/ 은 app/teams/2026jecheon/ 이고,
컨테이너 안에서는 /var/www/teams/2026jecheon/ 입니다.
가장 실용적인 부분
“컨테이너 안에 사이트가 있다” 면 파일을 고칠 때마다 컨테이너를 다시 만들어야 할까요. 아닙니다. 볼륨이 그 문제를 해결합니다.
compose.yaml 의
./public:/var/www/html 한 줄이 이 연결을 만듭니다.
# breathed.kr 에 올릴 때
$ cp 내파일.php /home/sites/breathed/public/
$ chown 33:33 /home/sites/breathed/public/내파일.php
$ chmod 644 /home/sites/breathed/public/내파일.php
# teams.breathed.kr 에 새 프로젝트를 올릴 때
$ cp -r 새프로젝트 /home/sites/insoo/app/teams/
$ chown -R 33:33 /home/sites/insoo/app/teams/새프로젝트
# insoo.breathed.kr 에 올릴 때
$ cp -r 자료 /home/sites/insoo/app/insoo/
$ chown -R 33:33 /home/sites/insoo/app/insoo/자료
33 이 뭔가 — 컨테이너 안 www-data 계정의 번호입니다.
리눅스는 계정을 이름이 아니라 번호로 판단하므로, 호스트에서 33 으로 맞추면
컨테이너 안에서 정상적으로 읽힙니다.
처음부터 다시 만든다면 이 순서
$ apt-get install -y ca-certificates curl gnupg
$ install -m 0755 -d /etc/apt/keyrings
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
$ chmod a+r /etc/apt/keyrings/docker.asc
$ cat > /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release; echo $VERSION_CODENAME)
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
$ apt-get update
$ mkdir -p /etc/docker /home/docker
$ cat > /etc/docker/daemon.json <<'EOF'
{
"data-root": "/home/docker",
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
"live-restore": true,
"default-address-pools": [ { "base": "172.20.0.0/14", "size": 24 } ]
}
EOF
왜 먼저인가 — 설치 후에 옮기려면 컨테이너를 전부 내려야 합니다. 이미지 몇 개만 받아도 수 GB 라, 작은 디스크는 금방 찹니다.
$ apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
$ systemctl enable --now containerd docker
$ docker info --format '{{.DockerRootDir}}' # /home/docker 확인
Docker 는 포트를 열 때 ufw 를 통하지 않고 iptables 에 직접 규칙을 넣습니다. Docker 가 건드리지 않는 DOCKER-USER 체인에 기본 차단을 둡니다.
$ cat > /usr/local/sbin/docker-firewall-guard <<'EOF'
#!/usr/bin/env bash
set -u
LAN="172.30.1.0/24" # 내부망에 맞게 수정
DOCKER_POOL="172.20.0.0/14" # daemon.json 의 주소 풀
DOCKER_BRIDGE="172.17.0.0/16"
apply() {
local ipt="$1"
"$ipt" -L DOCKER-USER -n >/dev/null 2>&1 || return 0
"$ipt" -F DOCKER-USER
"$ipt" -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
"$ipt" -A DOCKER-USER -s "$DOCKER_POOL" -j RETURN
"$ipt" -A DOCKER-USER -s "$DOCKER_BRIDGE" -j RETURN
"$ipt" -A DOCKER-USER -i lo -j RETURN
if [ "$ipt" = "iptables" ]; then
"$ipt" -A DOCKER-USER -s 127.0.0.0/8 -j RETURN
"$ipt" -A DOCKER-USER -s "$LAN" -j RETURN
fi
"$ipt" -A DOCKER-USER -j DROP
}
apply iptables
apply ip6tables
EOF
$ chmod 0755 /usr/local/sbin/docker-firewall-guard
주의 — 컨테이너 대역을 172.16.0.0/12 로 뭉뚱그리면 안 됩니다. 내부망이 172.30.1.0/24 라면 그 안에 포함되어 랜 전체가 허용돼 버립니다.
이걸 docker.service 뒤에 붙는 systemd 유닛으로 등록해 재부팅에도 유지되게 합니다.
$ ufw allow 22/tcp # ★ enable 전에 먼저
$ ufw limit 22/tcp
$ ufw allow 80/tcp
$ ufw allow 443/tcp
$ ufw show added # 22 가 있는지 눈으로 확인
$ ufw --force enable
순서를 지킬 것 — 22 를 허용하지 않고 enable 하면 원격 접속이 그 자리에서 끊깁니다.
$ mkdir -p /home/sites/mysite/{php/conf.d,nginx/conf.d,public,logs,db/data}
$ cd /home/sites/mysite
$ umask 077
$ cat > .env <<EOF
COMPOSE_PROJECT_NAME=mysite
SITE_HTTP_PORT=8083
MYSQL_ROOT_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 28)
MYSQL_DATABASE=mysite
MYSQL_USER=mysite
MYSQL_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 28)
EOF
$ chmod 600 .env
포트 — 8081(insoo)·8082(breathed) 는 쓰는 중이니 8083 부터 하나씩 늘려 배정합니다.
compose.yamlservices:
db:
image: mysql:9.7
restart: unless-stopped
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
# MySQL 8.4 부터 --default-authentication-plugin 은 제거됐다
- --authentication-policy=caching_sha2_password
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?}
MYSQL_DATABASE: ${MYSQL_DATABASE:?}
MYSQL_USER: ${MYSQL_USER:?}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?}
TZ: Asia/Seoul
volumes:
- ./db/data:/var/lib/mysql
# 포트를 열지 않는다 — php 컨테이너만 내부에서 접근한다
healthcheck:
test: ["CMD","mysqladmin","ping","-h","127.0.0.1","-u","root","-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
networks: [internal]
php:
build: { context: ./php } # 확장이 필요 없으면 image: php:8.5-fpm
restart: unless-stopped
depends_on:
db: { condition: service_healthy }
volumes:
- ./public:/var/www/html
- ./php/conf.d/zz-site.ini:/usr/local/etc/php/conf.d/zz-site.ini:ro
networks: [internal]
web:
image: nginx:1.28-alpine
restart: unless-stopped
depends_on: [php]
ports:
# ★ 반드시 127.0.0.1 — 빼면 인터넷에 그대로 열린다
- "127.0.0.1:${SITE_HTTP_PORT}:80"
volumes:
- ./public:/var/www/html:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
networks: [internal]
networks:
internal: { driver: bridge }
| 줄 | 뜻 |
|---|---|
| image: | 어떤 이미지로 켤지. 태그를 반드시 고정. latest 는 어느 날 갑자기 올라간다 |
| volumes: | 서버경로:컨테이너경로. :ro 는 읽기 전용 |
| ports: | 127.0.0.1:8083:80 — 앞의 IP 가 외부 노출을 막는다 |
| networks: | 같은 network 안에서만 통신. 그래서 php 가 db 라는 이름으로 DB 를 찾는다 |
| depends_on: | service_healthy 는 DB 가 준비될 때까지 기다린다 — 켜진 것과 다르다 |
| ${VAR:?} | .env 에서 읽고, 값이 없으면 기동을 거부한다 |
server {
listen 80;
server_name _;
root /var/www/html;
index index.php index.html;
client_max_body_size 64M;
# 호스트 nginx 가 넘겨준 실제 방문자 IP 를 로그에 남긴다
real_ip_header X-Forwarded-For;
set_real_ip_from 172.16.0.0/12;
real_ip_recursive on;
# 백업·임시 파일 차단 — 원본 코드에 .bak/.orig 가 섞여 있기 쉽다
location ~* \.(bak|orig|old|save|swp|tmp|premigrate)([^/]*)$ { deny all; }
location ~* \.(sql|log|sqlite3?|db)$ { deny all; }
location / { try_files $uri $uri/ =404; }
location ~ \.php$ {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
try_files $fastcgi_script_name =404;
set $path_info $fastcgi_path_info;
fastcgi_pass php:9000; # ← compose 의 서비스 이름
include fastcgi_params;
fastcgi_param PATH_INFO $path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS $http_x_forwarded_proto if_not_empty;
}
location ~ /\. { deny all; } # .env, .git 차단
}
fastcgi_pass php:9000 — IP 가 아니라 compose 서비스 이름입니다. 컨테이너가 재시작돼 IP 가 바뀌어도 이름은 그대로입니다.
$ docker compose config --quiet # 문법 검사
$ docker compose up -d
$ docker compose ps # db 가 healthy 인지
$ curl -I http://127.0.0.1:8083/ # ★ 호스트 nginx 연결 전에 확인
이 순서가 중요하다 — 뒤(컨테이너)가 응답하는 걸 확인한 다음 앞(호스트 nginx)을 붙입니다. 반대로 하면 502 가 났을 때 원인이 앞인지 뒤인지 알 수 없습니다.
upstream mysite_stack { server 127.0.0.1:8083; keepalive 16; }
server {
listen 443 ssl;
server_name mysite.breathed.kr;
ssl_certificate /etc/letsencrypt/live/breathed.kr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/breathed.kr/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
location / {
proxy_pass http://mysite_stack;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
$ nginx -t # ★ 반드시 먼저
$ systemctl reload nginx # reload 는 접속을 끊지 않는다
# 인증서에 이름 추가
$ certbot --nginx -d breathed.kr -d www.breathed.kr \
-d teams.breathed.kr -d insoo.breathed.kr -d mysite.breathed.kr
nginx -t 를 건너뛰지 말 것 — 문법 오류인 채로 restart 하면 nginx 가 아예 뜨지 않아 서버의 모든 사이트가 동시에 죽습니다.
$ ss -tlpn | grep 8083 # 127.0.0.1 인지 (0.0.0.0 이면 즉시 고칠 것)
$ ss -tlpn | grep 3306 # 아무것도 안 나와야 정상
$ iptables -S DOCKER-USER # 끝줄이 -j DROP
$ docker inspect mysite-web-1 --format '{{.HostConfig.RestartPolicy.Name}}'
문서에 안 나오는 것들 — 다음에 또 만난다
trap 01 · 이미지
php:8.5 에는 이미 들어 있는 확장이 있다mbstring 과 Zend OPcache 는 공식 이미지에 내장돼 있습니다. 모르고 다시 설치하면 만들 게 없어 이미지 빌드가 실패합니다.
cp: cannot stat 'modules/*': No such file or directory
확장을 추가하기 전에 항상 docker run --rm php:8.5-fpm php -m 으로 먼저 확인하세요.
trap 02 · 버전 차이
--default-authentication-plugin 은 제거됐습니다. 지정하면 기동이 실패하고, 더 나쁘게는 데이터 폴더가 반쯤 초기화된 채로 깨집니다. 그 경우 db/data 를 비우고 다시 시작해야 합니다. 후속 옵션은 --authentication-policy 입니다.
trap 03 · 보안
포트는 반드시 127.0.0.1:8083:80 형태로 씁니다. 8083:80 이라고만 쓰면 0.0.0.0 에 열려 인터넷에 그대로 노출되고, ufw 에서 막아놨어도 통하지 않습니다.
trap 04 · 서버 이전
파일 크기가 같아 보여도 InnoDB 는 메모리에 아직 안 내려간 페이지가 있어 일관성이 보장되지 않습니다. mysqldump --single-transaction 으로 덤프해서 적재하세요.
trap 05 · nginx
if 는 location 보다 먼저 실행된다server 레벨의 if ($host = …) { return 301; } 은 rewrite 단계에서 location 매칭을 앞지릅니다. 인증서 갱신용 /.well-known/acme-challenge/ location 을 아무리 앞에 둬도 리다이렉트가 먼저 먹습니다. 리다이렉트를 location / 안으로 옮겨야 합니다.
trap 06 · 작업 습관
docker compose build 2>&1 | tail — 빌드가 실패해도 성공으로 보입니다. 마지막 명령의 종료코드만 남기 때문docker compose exec 는 stdin 을 소비합니다. heredoc 으로 넘긴 스크립트 안에서 쓰면 나머지가 실행되지 않습니다 → </dev/null 필수grep -- '패턴' 경로 --exclude-dir=… — -- 뒤의 옵션은 파일명으로 취급돼 제외가 먹지 않습니다ufw limit 22/tcp 는 30초에 6회를 넘기면 REJECT 합니다. rsync·ssh 를 연달아 돌리면 자기 IP 가 걸립니다 → SSH ControlMaster 로 연결 재사용스택 폴더로 이동한 뒤 실행한다
$ cd /home/sites/insoo # 또는 /home/sites/breathed
$ docker compose ps # 상태
$ docker compose up -d # 켜기
$ docker compose down # 끄기 (데이터는 남는다)
$ docker compose restart php # 코드 바꾼 뒤 반영
$ docker compose logs -f php # 로그 실시간 (Ctrl+C 로 나옴)
$ docker compose build php # Dockerfile 고친 뒤
$ docker compose exec php bash # 컨테이너 안으로
# DB 접속
$ docker compose exec db mysql -uroot \
-p"$(grep MYSQL_ROOT_PASSWORD .env | cut -d= -f2)" insoo
# 호스트 쪽
$ nginx -t && systemctl reload nginx
$ certbot certificates
$ certbot renew --dry-run # 갱신이 되는지 미리 확인
$ ufw status
$ fail2ban-client status sshd
down 은 컨테이너만 지웁니다. DB 데이터는 db/data, 사이트 파일은
app/·public/ 에 서버 쪽에 남아 있으니 안심하고 써도 됩니다.
다시 up -d 하면 그대로 돌아옵니다.
2026-09-15 실측
| 항목 | 값 |
|---|---|
| 서버 | breathed-home · Ubuntu 26.04.1 LTS |
| CPU · 메모리 | 4 core i3-8121U · 7.2 GiB (+swap 4 GiB) |
| 디스크 | / 228G (204G 여유) · /home 469G (433G 여유) |
| 네트워크 | 공인 IP 가 랜카드에 직결 (NAT 아님) |
| Docker | 29.8.0 · Compose v5.5.1 · data-root /home/docker |
| 컨테이너 | 6개 · 합계 약 1.0 GiB |
| CMS 데이터 | 14 테이블 · 게시글 288 · 첨부 3,697 · 회원 54 |
| 업로드 | 12 GB (teams/uploads) |
| TLS | 인증서 1장에 이름 4개 · 만료 2026-12-13 · 자동 갱신 |
| 방화벽 | ufw (22 rate-limit · 80 · 443) + fail2ban + DOCKER-USER 기본 DROP |
| 백업 | 스택별 backup.sh — DB 14일 · 코드 8주 |
알아둘 것
랜카드가 proto dhcp 로 주소를 받고 있습니다. 통신사가 다른 IP 를 주면 도메인이 가리키는 곳이 어긋나 사이트가 멈춥니다. 고정 IP 인지 확인하거나 DDNS 를 걸어두어야 합니다.
업로드 12GB 는 자동 백업에서 빠져 있습니다. 같은 디스크에 복사하는 것은 디스크 고장에 무의미해서 일부러 제외했습니다. 외부 스토리지 정책이 별도로 필요합니다.