블로그로 돌아가기
8월 19, 2026가이드

VPS에서 Docker Compose로 애플리케이션을 배포하는 방법

Ubuntu에서 Docker Compose v2의 실용적인 프로덕션 가이드입니다. 엔진 설치, 네트워크와 볼륨이 있는 Compose 파일 작성, 비밀 처리, 앞에 리버스 프록시 두기, 당황하지 않는 업데이트, 명명된 볼륨 백업.

VPS에서 Docker Compose로 애플리케이션을 배포하는 방법

Docker Compose는 한 VPS에서 작은 스택을 돌리는 흔한 방법입니다. 웹 앱, 데이터베이스, Redis 캐시, 리버스 프록시를 하나의 YAML 파일로 기술합니다. Kubernetes가 아닙니다. 다중 노드 장애 조치를 얻지 못합니다. 새 Hiddence VPS에서 몇 분 만에 다시 만들 수 있는, 반복 가능하고 검토 가능한 구성을 얻습니다. 대부분의 사이드 프로젝트와 작은 제품이 바로 그것을 필요로 합니다.

이 글은 금요일 밤의 docker run보다 프로덕션에 가까운 것을 원한다고 가정합니다. Docker Engine과 Compose 플러그인을 설치하고, root가 아닌 배포 사용자를 만들고, 헬스 체크와 재시작 정책이 있는 Compose 파일을 쓰고, 비밀을 Git 밖에 두고, 인터넷에는 리버스 프록시 포트만 게시하고, 업데이트와 백업 절차를 정의합니다. 예제 스택은 전형적인 웹 앱 더하기 PostgreSQL 더하기 Caddy이지만, 같은 패턴은 Node, PHP, Python, 워커 무리에도 동작합니다.

단일 VPS에서 Compose가 여전히 의미 있는 이유

유행이라 Kubernetes로 뛰어든 뒤 주말을 웹사이트 하나용 YAML에 쓰는 사람이 있습니다. Compose는 읽기 쉽습니다. 파일을 버전 관리하고, 환경 변수를 문서화하고, 적용 전에 diff를 볼 수 있습니다. 격리는 충분합니다. 네트워크와 최소 권한 사용자를 썼다면 침해된 앱 컨테이너가 데이터베이스 UNIX 소켓을 들고 있으면 안 됩니다. 리소스 제한은 한 번의 메모리 누수가 VPS 전체를 멈추는 것을 막습니다.

  • 한 파일이 전체 스택을 기술
  • 명명된 볼륨은 컨테이너 재생성 후에도 남음
  • 내부 Docker 네트워크가 Postgres를 공용 인터넷에서 떼어 냄
  • restart: unless-stopped가 대부분의 재부팅을 커버
  • 첫 대가 비좁아지면 두 번째 VPS로 복사하기 쉬움

요구 사항

앱과 Postgres를 위한 RAM이 충분한 VPS를 쓰세요. 앱 + 데이터베이스 + 프록시의 현실적인 최소는 2 GB 플랜입니다. Ubuntu 24.04를 가정합니다. cgroups에 무엇을 하는지 읽지 않고 무작위 Snap에서 Docker를 설치하지 마세요. 아래 단계는 Docker 공식 apt 저장소를 사용합니다.

  • Ubuntu 22.04/24.04, root 또는 sudo
  • RAM 2 GB 권장(Postgres 없는 아주 작은 스택만 1 GB)
  • 자동 HTTPS를 원하면 VPS를 가리키는 도메인
  • 서버로 프로젝트를 복사할 Git 또는 다른 수단

1단계: Docker Engine과 Compose v2 설치

Compose v2는 docker compose(공백 있음)로 호출하는 플러그인이지, 옛 docker-compose Python 바이너리가 아닙니다. 최신 보안 패치를 받으려면 둘 다 Docker 저장소에서 설치하세요.

bash
ssh root@YOUR_VPS_IP
apt update && apt -y install 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

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null

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

docker version
docker compose version

2단계: 배포 사용자와 디렉터리 배치

root로 Compose를 돌리는 것은 되지만, docker 그룹에 속한 전용 사용자가 더 깨끗합니다. 프로젝트는 /root가 아니라 /opt 또는 /srv에 두어 백업과 권한이 분명하게 하세요. .env가 있는 디렉터리를 월드 쓰기 가능하게 만들지 마세요.

bash
adduser --disabled-password --gecos '' deploy
usermod -aG docker deploy
mkdir -p /srv/app
chown deploy:deploy /srv/app
chmod 750 /srv/app

# Log in as deploy for the rest of the file editing:
# su - deploy
# cd /srv/app

3단계: 프로덕션을 염두에 둔 Compose 파일 작성

아래 파일은 템플릿입니다. 앱 이미지를 자신의 것으로 바꾸세요. Postgres는 0.0.0.0:5432에 게시되지 않습니다. 80과 443에 게시되는 것은 Caddy뿐입니다. 헬스 체크는 아직 데이터베이스를 마이그레이션 중인 앱으로 리버스 프록시가 트래픽을 보내는 것을 막습니다. 이미지 태그를 고정하세요. latest는 뜻밖의 파손이 일어나는 방식입니다.

bash
# /srv/app/compose.yaml
services:
  caddy:
    image: caddy:2.8-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      app:
        condition: service_healthy

  app:
    image: ghcr.io/example/webapp:1.4.2
    restart: unless-stopped
    env_file: .env
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
      interval: 10s
      timeout: 3s
      retries: 10
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file: .env
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks:
      - backend

networks:
  frontend: {}
  backend: {}

volumes:
  pgdata:
  caddy_data:
  caddy_config:

4단계: 비밀, .env, Caddyfile

비밀번호는 서버의 .env에 두고 chmod 600 하고 그 파일을 커밋하지 마세요. openssl rand -base64 32로 비밀번호를 생성하세요. Caddyfile은 Docker 네트워크의 앱 서비스 이름으로 리버스 프록시하기만 하면 됩니다.

bash
# /srv/app/.env (permissions 600)
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me-long-random
POSTGRES_DB=app
# plus any APP_SECRET / NEXTAUTH_SECRET your image needs

# /srv/app/Caddyfile
app.example.com {
    encode gzip
    reverse_proxy app:8000
}

chmod 600 /srv/app/.env
cd /srv/app
docker compose up -d
docker compose ps
docker compose logs -f --tail=100

5단계: 방화벽, 로그, 리소스 제한

UFW는 22, 80, 443만 허용해야 합니다. Docker는 게시된 포트에 대해 UFW를 우회하는 경우가 있습니다. 엄격한 호스트 방화벽이 필요하면 해당 Ubuntu 버전의 현재 Docker + UFW 상호작용을 찾아보거나, 포트를 127.0.0.1에만 게시하고 앞에 호스트 Caddy를 두세요. Compose에서 메모리 제한을 두어 Postgres가 VPS를 잡아먹지 않게 하세요.

bash
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

# Optional in compose.yaml under app or db:
#    deploy:
#      resources:
#        limits:
#          memory: 512M

# Logs (do not let json-file grow forever):
# { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
# in /etc/docker/daemon.json then: systemctl restart docker

6단계: 업데이트와 롤백

동작하는 지루한 업데이트는 새 이미지를 pull하고, up -d하고, 헬스 체크를 보고, 이전 태그를 git에 남겨 다시 고정할 수 있게 하는 것입니다. 트래픽 수신을 멈출 생각이 아니면 프로덕션 데이터베이스에서 docker compose down 하지 마세요. down -v는 볼륨을 지웁니다. 재시작이 아니라 와이프입니다.

bash
cd /srv/app
git pull   # if the Compose file lives in git

# Change the image tag in compose.yaml, then:
docker compose pull
docker compose up -d
docker compose ps

# Rollback: set the old tag, pull, up -d again
# NEVER: docker compose down -v   # this deletes named volumes

명명된 볼륨 백업

VPS 스냅샷은 좋습니다. 다른 머신에 복원해야 하면 논리 Postgres 덤프가 더 낫습니다. cron으로 예약하세요. 복원을 한 번 시험하지 않으면 백업이 없습니다. 백업이길 바라는 파일이 있을 뿐입니다.

bash
# Postgres dump while the db container is running:
docker compose exec -T db pg_dump -U app app | gzip > /var/backups/app-$(date +%F).sql.gz

# Copy off-box
# scp /var/backups/app-*.sql.gz backup-host:~

# Restore sketch (maintenance window):
# gunzip -c app-2026-08-19.sql.gz | docker compose exec -T db psql -U app app

문제 해결

docker compose ps와 docker compose logs service를 읽으세요. 이미지 pull 실패는 보통 ghcr/Docker Hub 속도 제한이거나 로그인 없는 비공개 이미지입니다. Caddy의 502는 앱이 건강하지 않거나 프록시 호스트 이름이 틀린 것입니다(Caddy 컨테이너 안에서는 localhost가 아니라 Compose 서비스 이름). 볼륨 권한 오류는 이미지가 uid 1000으로 돌고 호스트 디렉터리가 root인 경우가 많습니다.

  • compose: command not found — 옛 바이너리 이름을 설치함. docker compose 사용
  • port is already allocated — 다른 것이 80/443을 소유(Apache, Nginx, 다른 Caddy)
  • database connection refused — 앱이 Postgres 준비 전에 시작. healthcheck + depends_on condition 사용
  • 디스크 가득 참 — docker system df, 그다음 사용하지 않는 이미지를 신중히 prune
  • docker.sock에서 permission denied — 사용자가 docker 그룹에 없거나 새 로그인 세션이 필요

보안

데이터베이스 포트를 게시하지 마세요. privileged: true를 쓰지 마세요. 컨테이너 관리자를 일부러 쓰는 것이 아니면 앱 컨테이너에 /var/run/docker.sock을 마운트하지 마세요. 엔진을 업데이트하세요. 직접 빌드하면 이미지를 스캔하세요. 디스크의 .env는 여전히 비밀 파일입니다. 그 상자에 SSH할 수 있는 사람을 제한하세요.

  • 공개 5432 / 3306 / 6379 없음
  • 이미지 다이제스트 또는 최소한 불변 태그 고정
  • chmod 600 .env
  • 호스트 OS의 unattended-upgrades는 여전히 중요
  • 앱마다 Compose 프로젝트 하나로 폭발 반경을 작게

  • compose.yaml은 git에. .env.example에는 실제 비밀을 넣지 않음
  • 선택 워커는 profiles(docker compose --profile workers up -d)
  • VPS 크기를 정할 때 docker stats를 보기
  • 소스 코드 바인드 마운트는 프로덕션 패턴이 아님 — 이미지를 굽기
  • 스택이 여러 VPS로 커진 뒤에 오케스트레이션을 보세요. 그 전이 아닙니다

한 VPS의 프로덕션에 가까운 Compose 설정은 공식 Docker 패키지, 잠긴 .env, 데이터베이스를 게시하지 않는 YAML, 헬스 체크, 80/443의 리버스 프록시, UFW, 롤백할 수 있는 이미지 태그, 실제로 한 번 복원한 Postgres 덤프입니다. 이 글의 템플릿에서 시작해 앱 이미지를 바꾸고, docker compose down -v를 rm -rf와 같은 존중으로 파괴적 명령으로 다루세요.