GitHub Actions와 Watchtower로 집 서버에 접속하지 않고 배포하는 방법
코드를 main에 올리면 GitHub Actions가 도커 이미지를 ghcr.io에 올리고, 집 서버의 Watchtower가 5분마다 확인해 컨테이너를 바꿔 끼우는 구성입니다. 워크플로·compose 설정, 자동으로 바꿀 컨테이너만 고르는 라벨, 실제 로그와 되돌리는 방법을 적었습니다.
집에 있는 2018년 맥미니(램 24GB) 한 대에서 도커 컨테이너 18개를 돌리고 있습니다. 서버 전체로 봐도 메모리는 24GB 중 4GB 정도만 씁니다. 이 중 웹 3개와 백엔드 1개는 코드를 main에 올리면 서버에 접속하지 않아도 새 버전으로 바뀝니다. 서버에 ssh로 들어가는 건 배포할 때가 아니라 뭔가 깨졌을 때뿐입니다.
구성은 두 조각입니다. GitHub Actions가 도커 이미지를 만들어 GitHub 컨테이너 레지스트리(ghcr.io)에 올리고, 서버에서 도는 Watchtower가 5분마다 새 이미지가 있는지 확인해서 있으면 받아 컨테이너를 바꿔 끼웁니다. 아래에 그 설정과 실제로 바뀌는 데 걸린 시간, 되돌리는 방법을 적었습니다.

밀어 넣는 배포와 가져가는 배포
GitHub Actions로 배포하는 흔한 방법은 워크플로 마지막에 서버로 ssh 접속해서 docker compose pull && docker compose up -d를 실행하는 것입니다. 이렇게 하려면 GitHub에 서버의 ssh 키를 넣어 두고, 밖에서 서버의 ssh 포트에 닿을 수 있어야 합니다.
Watchtower를 쓰면 GitHub은 이미지를 레지스트리에 올리는 데서 끝나고, 서버가 레지스트리에서 이미지를 가져갑니다.
| ssh로 밀어 넣기 | Watchtower로 가져가기 | |
|---|---|---|
| GitHub에 두는 것 | 서버 ssh 키 | 없음 |
| 서버로 들어오는 연결 | ssh 포트 필요 | 없음 |
| 반영까지 | 워크플로 끝나면 바로 | 최대 확인 주기(5분)만큼 늦음 |
| 배포 순서 제어 | 스크립트로 자유롭게 | 컨테이너 단위 교체만 |
집 서버는 공유기 포트를 하나도 열지 않고 운영하고 있어서 Watchtower로 가져가는 방식을 골랐습니다. 외부 공개는 Cloudflare Tunnel로 포트포워딩 없이 집 서버를 외부에 여는 방법으로 하고 있습니다.
1. GitHub Actions에서 이미지 만들어 ghcr.io에 올리기
웹 저장소의 워크플로는 main에 push가 들어오면 린트·테스트·빌드를 돌리고, 전부 통과하면 이미지를 올립니다. 테스트가 실패하면 이미지가 안 올라가므로 서버도 바뀌지 않습니다.
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ... 설치·린트·테스트·빌드 ...
- name: Set image tags
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/내-조직/내-앱:latest
ghcr.io/내-조직/내-앱:sha-${{ env.SHORT_SHA }}
태그는 두 개를 붙였습니다. 서버는 latest를 따라가고, sha- 태그는 문제가 생겼을 때 이전 버전으로 되돌리는 데 씁니다.
백엔드 저장소는 push마다 돌리지 않고, 수동 실행과 매일 새벽 4시 반(KST) 한 번으로 두었습니다. GitHub Actions 무료 시간을 아끼려는 설정이라 백엔드 변경은 다음 날 아침에 반영됩니다. 무료 시간을 다 쓴 뒤 러너를 집 컴퓨터로 옮긴 이야기는 깃허브 액션 무료 분을 다 쓰면 집 컴퓨터를 러너로 붙인다에 적었습니다.
2. 서버에서 ghcr.io 로그인
비공개 저장소의 이미지는 서버도 로그인해야 받을 수 있습니다. GitHub에서 read:packages 권한만 있는 토큰을 만들어 서버에서 한 번 로그인합니다.
echo "$GHCR_TOKEN" | docker login ghcr.io -u 내-깃허브-아이디 --password-stdin
로그인 정보는 ~/.docker/config.json에 저장됩니다. 맥에서 Docker 설정이 키체인에 인증을 저장하도록(credsStore) 되어 있으면 이 파일에 인증이 안 남아서 Watchtower가 읽지 못합니다. 파일의 auths 아래 ghcr.io 항목에 auth 값이 있는지 확인합니다.
3. Watchtower 컨테이너 띄우기
Watchtower도 같은 compose 파일에 컨테이너 하나로 넣습니다.
services:
watchtower:
image: containrrr/watchtower:latest
restart: unless-stopped
command: --interval 300 --cleanup --label-enable
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HOME}/.docker/config.json:/config/config.json:ro
environment:
DOCKER_CONFIG: /config
DOCKER_API_VERSION: "1.41"
command에 넣은 옵션 세 개는 이렇습니다.
--interval 300· 300초(5분)마다 레지스트리에 새 이미지가 있는지 확인합니다.--cleanup· 바꿔 끼운 뒤 옛 이미지를 지웁니다. 안 켜면 배포할 때마다 디스크에 옛 이미지가 쌓입니다.--label-enable· 라벨을 붙인 컨테이너만 바꿉니다. 다음 절에서 설명합니다.
DOCKER_API_VERSION은 맥에서 OrbStack을 쓸 때 넣었습니다. OrbStack의 도커 데몬이 Watchtower 기본 API 버전을 거부해서 버전을 맞춰 줘야 합니다. Docker Desktop이나 리눅스 서버라면 없어도 됩니다.
Watchtower에 넘기는 docker.sock은 서버의 도커 전체를 다룰 수 있는 통로입니다. 이걸 받은 컨테이너는 다른 컨테이너를 멈추거나 새로 띄울 수 있으므로, Watchtower는 공식 이미지(containrrr/watchtower)만 쓰고 다른 컨테이너에는 소켓을 넘기지 않습니다.
4. 자동으로 바꿀 컨테이너에만 라벨 붙이기
--label-enable 없이 띄우면 Watchtower는 서버의 컨테이너 전부를 확인합니다. 데이터베이스나 외부 터널처럼 버전이 저절로 바뀌면 곤란한 것까지 들어가므로 자동 배포할 컨테이너에만 라벨을 붙였습니다.
web:
image: ghcr.io/내-조직/내-앱:latest
labels:
com.centurylinklabs.watchtower.enable: "true"
지금 서버의 컨테이너 18개 중 이 라벨이 붙은 건 4개(백엔드 1, 웹 3)입니다. 데이터베이스·터널·모니터링 같은 나머지 14개는 자동으로 바뀌지 않습니다. Watchtower 로그의 Scanned=4가 이 4개를 확인했다는 뜻입니다.
5. 실제로 바뀌는 데 걸린 시간
일요일 아침 백엔드가 새 이미지로 바뀔 때의 Watchtower 로그입니다(시각은 UTC).
22:07:52 Found new ghcr.io/내-조직/내-앱:latest image
22:07:53 Stopping /내-앱 with SIGTERM
22:07:57 Session done Failed=0 Scanned=4 Updated=1
새 이미지를 찾고 컨테이너를 바꿔 끼워 끝나기까지 5초였습니다. 그 사이에는 해당 서비스가 잠깐 응답하지 않습니다. 끊기면 안 되는 서비스라면 이 방식보다 컨테이너를 두 개 띄워 번갈아 바꾸는 구성이 필요합니다.
새 이미지가 없을 때는 5분마다 Updated=0만 찍히고 아무것도 바뀌지 않습니다. 백엔드처럼 매일 새벽에 빌드하는 경우에도 코드가 안 바뀌었으면 같은 이미지가 올라가므로 재시작되지 않습니다.
6. 문제가 생기면 이전 버전으로 되돌리기
latest로 새 버전이 나갔는데 문제가 있으면, compose 파일에서 그 서비스의 태그를 직전 sha- 태그로 고정하고 다시 띄웁니다.
web:
image: ghcr.io/내-조직/내-앱:sha-1a2b3c4 # 직전 정상 버전
docker compose up -d web
태그를 고정해 두면 Watchtower가 확인해도 그 태그의 이미지는 바뀌지 않으므로 자동 배포가 멈춥니다. 코드를 고쳐 main에 다시 올린 뒤 태그를 latest로 돌려놓으면 다시 자동으로 따라갑니다.
이 방식이 안 맞는 경우
- 배포 순서가 있는 경우 · 데이터베이스 마이그레이션을 먼저 하고 앱을 바꿔야 한다면, Watchtower는 컨테이너를 하나씩 바꿀 뿐이라 순서를 보장하지 않습니다.
- 바로 반영돼야 하는 경우 · 최대 5분 늦습니다. 주기를 줄일 수는 있지만 레지스트리 확인 횟수가 그만큼 늘어납니다.
- 테스트 없이 이미지를 올리는 경우 ·
latest에 올라간 이미지는 그대로 서버에 나갑니다. 그래서 워크플로에서 테스트가 통과해야 이미지가 올라가도록 순서를 두었습니다.