생산성

VPS에서 Docker Compose + Caddy 자동 HTTPS + GitHub Actions 무중단 배포 실전 구성과 트러블슈팅

VPS에서 Docker Compose + Caddy 자동 HTTPS + GitHub Actions 무중단 배포 실전 구성과 트러블슈팅

Nginx 설정 파일 씨름하다 하루 날려본 적 있죠? 저도 그랬어요.

그런데 Caddy 알고 나서 그 시간이 확 줄었어요. git push 하나로 HTTPS 자동 갱신에 무중단 배포까지 — Docker Compose + Caddy + GitHub Actions 조합이 소규모 팀이나 사이드 프로젝트에서 비용 대비 가장 균형 잡힌 배포 스택으로 자리잡은 이유예요.

이런 분들에게 맞아요:

  • 사이드 프로젝트나 소규모 서비스를 운영 중인 개발자
  • AWS ECS나 K8s 쓰기엔 과한데, git push로 배포하고 싶은 분
  • HTTPS 설정마다 Let’s Encrypt 씨름하기 싫은 분

핵심 요약

  • Caddy는 Let’s Encrypt 인증서를 자동 발급/갱신해줘서 HTTPS 설정에 별도 작업이 필요 없어요.
  • Docker Compose의 Blue/Green 전략을 쓰면 다운타임 없이 컨테이너를 교체할 수 있어요.
  • GitHub Actions에서 SSH로 VPS에 접속해 docker compose up -d를 실행하는 구조가 핵심이에요.
  • Caddy v2.8 기준, Caddyfile 하나로 리버스 프록시 + 자동 HTTPS + 헬스체크를 모두 처리해요.
  • 배포 실패 시 롤백을 위한 docker compose ps 기반 상태 확인 스크립트가 필수예요.

왜 이 조합인가

Kubernetes는 강력하지만 오버스펙이에요. 월 $20짜리 VPS에 K8s 올리는 건 트럭에 쇼핑백 하나 싣는 격이거든요. Heroku 같은 PaaS는 비용이 예측 불가하고요.

그래서 이 세 조합이에요:

  • GitHub Actions — 퍼블릭 레포 기준 무료, 프라이빗도 월 2,000분 무료
  • Caddy v2 — 자동 HTTPS 기본 탑재. certbot 따로 돌릴 필요 없어요
  • Docker Compose — 로컬 개발 환경과 프로덕션 환경의 간극을 줄여줘요

“무중단(Zero-downtime)“이 거창하게 들리지만 실제로는 단순해요. 구버전 컨테이너가 살아있는 동안 신버전을 띄우고, 준비되면 트래픽을 넘기는 거예요. 이 패턴을 Blue/Green 배포라고 부르고, Caddy의 리버스 프록시가 이 전환을 담당해요.


스택 비교: 이 조합 vs 대안들

항목Compose + Caddy + GH ActionsNginx + Certbot + GH ActionsCoolify
HTTPS 설정자동 (Caddyfile 2줄)Certbot 수동 + cronUI에서 클릭
배포 복잡도중간 (YAML 직접 작성)높음 (nginx conf 필수)낮음 (UI 기반)
디버깅 투명성높음높음낮음 (블랙박스)
Blue/Green 지원스크립트로 가능스크립트로 가능제한적

Coolify가 편하긴 한데, 뭔가 안 될 때 원인 찾기가 어려워요. Nginx는 레퍼런스가 많지만 HTTPS 갱신 자동화에서 한 번씩 막혀요. Caddy는 그 중간 — 설정은 단순하면서 투명성은 살아있어요.


단계별 구성 가이드

사전 준비

  • Ubuntu 22.04 이상 VPS (DigitalOcean, Hetzner, Vultr 등)
  • 도메인 A 레코드가 VPS IP를 가리키고 있어야 해요
  • Docker 및 Docker Compose v2 설치 완료

Step 1: VPS에 Docker 설치

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

docker --version        # Docker version 26.x.x 확인
docker compose version  # Docker Compose version v2.x.x 확인

로그아웃 후 재접속해야 그룹 변경이 적용돼요. 이거 빠뜨리면 Actions에서 permission denied 에러 만나요.


Step 2: Caddyfile 작성

your-domain.com {
    handle /api/* {
        reverse_proxy app:8080
    }
    handle {
        reverse_proxy frontend:3000
    }
    log {
        output file /var/log/caddy/access.log
    }
}

appfrontend는 Docker Compose의 서비스 이름이에요. 같은 Docker 네트워크 안에 있으면 서비스 이름으로 바로 통신할 수 있거든요.


Step 3: docker-compose.yml 작성

version: "3.9"

services:
  caddy:
    image: caddy:2.8-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data      # 인증서 영구 저장
      - caddy_config:/config
    networks:
      - app_network
    restart: unless-stopped

  app:
    image: ghcr.io/your-username/your-app:${APP_VERSION:-latest}
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - NODE_ENV=production
    networks:
      - app_network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s

networks:
  app_network:
    driver: bridge

volumes:
  caddy_data:    # 인증서 데이터 — 절대 삭제하지 마세요
  caddy_config:

healthcheck가 핵심이에요. 이게 없으면 컨테이너가 뜨는 중에 트래픽이 들어와서 500 에러가 발생해요.


Step 4: GitHub Actions 워크플로우

name: Deploy to VPS

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: GitHub Container Registry 로그인
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker 이미지 빌드 & 푸시
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: VPS에 배포
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/myapp
            docker compose pull app
            docker compose up -d --no-deps --wait app
            docker compose ps

--wait 플래그가 중요해요. healthcheck가 통과할 때까지 기다렸다가 구버전을 종료해주거든요. 이게 무중단의 핵심이에요.


실전 트러블슈팅

Caddy가 인증서를 못 받아요 도메인 A 레코드가 아직 VPS IP를 안 가리킬 때 발생해요. dig your-domain.com으로 확인하고, 전파까지 최대 48시간 기다려야 해요. 급하면 tls internal로 로컬 인증서로 먼저 테스트하세요.

--wait 실행 중 timeout healthcheckstart_period를 늘려주세요. JVM 앱이나 초기화가 무거운 앱은 30~60초가 필요할 수 있어요.

caddy_data 볼륨 실수로 삭제 인증서가 날아가면 Let’s Encrypt 재발급이 Rate Limit에 걸려요. 도메인당 주 5회 제한이에요. docker compose down -v는 절대 쓰지 마세요. 저도 이거 한 번 당했어요.


프로덕션 체크리스트

  • .env 파일은 .gitignore에 반드시 포함
  • caddy_data 볼륨 백업 주기 설정 (월 1회 이상)
  • healthcheck 엔드포인트가 DB 연결 상태까지 확인하는지 검증
  • VPS 방화벽에서 80, 443 포트만 열고 8080 등 앱 포트는 닫기
  • 배포 실패 시 Slack/Discord 알림 연결 (if: failure() 활용)

마무리

git push 하나로 HTTPS 자동 갱신과 무중단 배포를 동시에 해결하는 스택이에요. K8s 없이도 충분히 프로덕션 수준의 파이프라인을 만들 수 있거든요.

처음엔 Caddyfile 설정과 Compose healthcheck가 낯설 수 있어요. 그래도 한 번 손에 익히면 새 프로젝트마다 이 템플릿 그대로 가져다 쓸 수 있어요. 실제로 저는 이 구조를 두 번째 프로젝트부터 복붙으로 시작했어요.

다음 단계로 추천하는 것들:

  • Caddy 공식 문서: caddyserver.com/docs
  • appleboy/ssh-action 심화 옵션 (타임아웃, 다중 서버 배포)
  • 트래픽이 늘면 Compose 대신 Docker Swarm으로 수평 확장 고려

막히는 부분 있으면 댓글로 남겨주세요. 같이 해결해봐요.

참고자료

  1. Gighub Action , Docker Compose, Nginx, code deploy 무중단 배포
  2. DevOps 골든패스 2026: GitHub Actions와 배포 안전장치 설계 | Chaos and Order
  3. [CI/CD] blue/green 무중단 배포 (Docker, Github Actions, Nginx) — J_hzlo

Photo by Rubaitul Azad on Unsplash