Dockhand를 이용한 Docker GitOps 구현

손으로 관리하던 docker-compose와 평문 .env를 Dockhand 기반 GitOps로 옮긴 과정. 단일 Proxmox VM에서 CI는 GitHub Actions, CD는 Dockhand가 맡는 구조를 정리했습니다.

Share
Dockhand를 이용한 Docker GitOps 구현
Photo by Yancy Min / Unsplash

GreedyLabs는 Proxmox 위에 Docker 워크로드를 담당하는 VM(greedylabs) 하나를 두고, 그 위에 개인 및 사이드 서비스들을 올려 운영하고 있습니다. 처음에는 구조가 단순했습니다. 서버에 SSH로 접속해 docker-compose.yml을 직접 작성하고 docker compose up -d를 실행하면 그걸로 끝이었으니까요.

문제는 서비스 수가 늘어나면서 시작됐습니다. 어떤 compose 파일이 최신인지, 무엇을 언제 바꿨는지, 문제가 생겼을 때 어떤 상태로 되돌려야 하는지가 점차 서버 디스크와 기억에만 의존하게 됐습니다. 변경 이력이 Git이 아니라 서버 안에만 존재하는 상태, 한마디로 형상 관리되지 않는 운영이었습니다.

여기에 한 가지 부담이 더 있었습니다. 바로 시크릿 관리입니다. DB 비밀번호, OIDC 클라이언트 시크릿, 외부 API 키 같은 민감한 값들이 각 서비스의 .env 파일 안에 평문으로 흩어져 있었습니다. 서비스가 늘수록 이 평문 시크릿들이 서버 곳곳에 쌓여 갔고, 어떤 값이 어디에 있는지, 누가 언제 갱신했는지를 추적하기가 점점 어려워졌습니다. 백업에도 평문 비밀이 그대로 딸려 들어가니 마음이 편치 않았습니다. 형상 관리가 안 되는 운영과 평문 시크릿의 산재, 이 두 가지가 GitOps 도입을 결심하게 만든 직접적인 계기였습니다.

이 문제를 해결하기 위해 GitOps를 도입했습니다. compose 정의를 Git에 두고 Git을 단일 진실 공급원(single source of truth)으로 삼되, 시크릿은 평문 파일이 아니라 Dockhand가 암호화해 보관하는 방식으로 옮겼습니다. 이 글은 그 과정에서 선택한 도구인 Dockhand와, 단일 호스트 환경에 맞춰 실제로 구축한 파이프라인을 정리한 기록입니다. 비슷한 규모로 자체 호스팅 인프라를 운영하시는 분들께 참고가 되었으면 합니다.

GreedyLabs 인프라 구성 한눈에 보기

전체 구성은 다음과 같습니다. Proxmox 위의 단일 VM(greedylabs) 안에서 cloudflared가 systemd 서비스로 직접 구동되고, 그 아래 Docker 위에 Dockhand가 GitOps 배포를 담당하는 구조입니다.

flowchart TB
  user([사용자 브라우저])
  cf["Cloudflare<br/>DNS · CDN · Access"]
  tun{{"Cloudflare Tunnel"}}
  user --> cf --> tun

  subgraph VM["Proxmox · VM — greedylabs"]
    direction TB
    cfd["cloudflared (systemd)<br/>ingress 라우팅"]
    subgraph DOCKER["Docker · Dockhand 관리"]
      direction TB
      dockhand["Dockhand · GitOps 배포"]
      subgraph STACKS["compose 스택"]
        direction TB
        subgraph APPS["서비스"]
          direction LR
          eumgil["eumgil"]
          kindly["kindly-responder"]
          runner["greedyrunner"]
          reader["greedyreader"]
        end
        subgraph COMMON["공통 서비스"]
          direction LR
          kc["keycloak"]
          garage["garage"]
          umami["umami"]
        end
        pg[("postgres · shared-db")]
      end
    end
  end

  %% 사용자 접근 (실선): tunnel → cloudflared → Docker 스택
  tun --> cfd
  cfd -->|hostname 라우팅| STACKS

  %% 배포 (점선)
  gh["GitHub<br/>앱 + gitops 레포"]
  ghcr[("ghcr.io")]
  gh -. build/push .-> ghcr
  ghcr -. 웹훅 + image pull .-> dockhand
  gh -. compose (git) .-> dockhand
  dockhand -. compose up -d .-> STACKS

  %% 내부 연결
  eumgil --> pg
  kindly --> pg
  runner --> pg
  eumgil -. OIDC .-> kc

  classDef access stroke:#2563eb,stroke-width:2px;
  classDef deploy stroke:#d97706,stroke-width:2px;
  class user,cf,tun,cfd access;
  class gh,ghcr,dockhand deploy;

구성 요소를 정리하면 다음과 같습니다.

  • Proxmox · VM (greedylabs): Proxmox 하이퍼바이저 위에 Docker 워크로드 전용 VM 하나를 두고 운영합니다. 호스트와 컨테이너 워크로드의 경계를 명확히 하기 위한 선택입니다.
  • cloudflared (systemd): Cloudflare Tunnel을 통해 포트 포워딩 없이 외부 트래픽을 받습니다. 컨테이너가 아니라 VM에 직접 설치된 systemd 서비스로 동작하며, hostname 기준으로 ingress 라우팅을 처리해 각 Docker 스택으로 트래픽을 흘려보냅니다. 인바운드 포트를 열지 않아도 되므로 공격 표면이 크게 줄어듭니다.
  • Docker · Dockhand: VM 위 Docker를 관리하는 대시보드이자, 이번 글의 주인공인 GitOps 엔진입니다. compose 스택 전체를 git 기반으로 배포·관리합니다.
  • compose 스택: eumgil, kindly-responder, greedyrunner, greedyreader 같은 애플리케이션과 keycloak, garage, umami 같은 공통 서비스, 그리고 이들이 공유하는 postgres(shared-db)로 구성됩니다.

사용자 접근 경로(파란 실선)는 Cloudflare → Tunnel → cloudflared → Docker 스택 순으로 흐르고, 배포 경로(주황 점선)는 GitHub → ghcr.io / 웹훅 → Dockhand → compose 스택 순으로 흐릅니다. 트래픽과 배포가 완전히 분리된 두 평면이라는 점이 이 구성의 핵심입니다.

왜 Dockhand였나

GitOps라고 하면 흔히 ArgoCD나 Flux를 떠올리지만, 그것은 Kubernetes 생태계의 도구입니다. GreedyLabs의 환경은 단일 호스트 Docker Compose이고, 여기에 Kubernetes를 끌어들이는 것은 명백한 오버엔지니어링이었습니다.

Dockhand는 실시간 컨테이너 관리, Compose 스택 오케스트레이션, 멀티 환경 지원을 제공하는 Docker 관리 애플리케이션이며, 핵심은 webhook과 auto-sync를 통해 Git 저장소에서 스택을 배포하는 Git 통합 기능이 기본으로 포함돼 있다는 점입니다. 한 리뷰에서는 이 도구를 SSO, 취약점 스캐닝, GitOps webhook, 멀티 호스트 관리, 스케줄 자동화를 무료 티어에 담은 자체 호스팅 Docker 관리 인터페이스로 소개하기도 했습니다.

선택 이유를 정리하면 다음과 같습니다.

  • Docker에 집중된 GitOps: Kubernetes 없이 compose 스택 그대로 GitOps를 구현합니다. GreedyLabs 규모에 정확히 맞는 무게입니다.
  • Git 통합이 무료 티어에 포함: SSO/OIDC와 webhook 기반 Git 배포가 유료 장벽 뒤에 숨어 있지 않습니다.
  • 암호화된 시크릿 관리: 앞서 언급한 평문 .env의 부담을 덜어주는 부분입니다. Dockhand의 시크릿은 AES-256-GCM으로 암호화되며 평문으로 디스크에 기록되지 않습니다. compose는 git에, 민감한 값은 암호화 저장소에 분리해 둘 수 있습니다.
  • 가벼운 설치: SQLite를 기본값으로 사용해 별도의 DB 설정 없이 단일 docker run 명령으로 배포되며, 라즈베리파이 4부터 멀티 노드 홈랩 환경까지 구동됩니다.
  • YAML을 Git에 그대로 보관: 일부 도구처럼 설정을 DB에 숨겨두지 않고, compose 파일을 저장소에서 직접 가져옵니다. 형상 관리 관점에서 투명성이 높습니다.
  • 보안 지향 설계: Wolfi 패키지 기반으로 직접 빌드한 하드닝 이미지를 사용하고, 자동 업데이트 시 새 이미지를 임시 태그로 받아 스캔한 뒤 기준을 초과하면 폐기하는 safe-pull 보호를 제공합니다.
참고로 Dockhand는 Business Source License 1.1(BSL 1.1) 하에 배포됩니다. 개인 사용·사내 내부 사용·비영리·교육·평가 목적은 무료이지만, Dockhand 자체를 상용 SaaS/호스팅 서비스로 제공하는 것은 허용되지 않습니다. 개인 인프라 운영 목적에는 제약이 없습니다.

GitOps 흐름 설계

GreedyLabs가 구축한 파이프라인의 전체 흐름은 다음과 같습니다.

flowchart TD
  A["개발자: 코드 push"] --> B["GitHub Actions 트리거"]
  B --> C["컨테이너 이미지 빌드"]
  C --> D["GitHub Packages<br/>(ghcr.io)로 push"]
  D --> E["Actions가 Dockhand<br/>webhook 호출 (HMAC 서명)"]
  E --> F["Dockhand: Git 저장소 pull"]
  F --> G["ghcr.io에서 이미지 re-pull"]
  G --> H["컨테이너 재배포"]

  subgraph CI["CI — GitHub Actions 담당"]
    C
    D
  end
  subgraph CD["CD — Dockhand 담당"]
    F
    G
    H
  end

여기서 책임이 두 갈래로 명확히 나뉩니다.

  • CI(이미지 빌드): GitHub Actions가 담당합니다. 코드를 이미지로 빌드해 ghcr.io에 push합니다.
  • CD(배포): Dockhand가 담당합니다. webhook 신호를 받아 compose 스택을 최신 이미지로 재배포합니다.

이렇게 하면 인프라 정의의 변경(compose 파일)애플리케이션 코드의 변경(이미지)이 깔끔하게 분리됩니다. 두 관심사가 서로 다른 트리거와 책임자를 갖게 되므로, 추후 문제 추적과 롤백이 훨씬 단순해집니다.

Git 연동과 토큰 권한 설정

Dockhand가 GitOps를 하려면 두 가지 연동이 필요합니다. 하나는 compose 파일을 가져오기 위한 Git 저장소 연동, 다른 하나는 이미지를 내려받기 위한 컨테이너 레지스트리 연동입니다. 각각 필요한 토큰과 권한이 다르므로 나눠서 정리합니다.

Git 저장소 연동 (compose pull용)

Dockhand가 GitOps 레포에서 compose 파일을 pull하려면 저장소 읽기 권한이 필요합니다. 공개 저장소라면 인증 없이도 되지만, 비공개 저장소라면 토큰을 발급해 등록해야 합니다.

GitHub Fine-grained personal access token 기준으로는 다음 권한이면 충분합니다.

  • Repository access: 해당 GitOps 레포만 선택 (최소 권한 원칙)
  • Repository permissions → Contents: Read-only

클래식 토큰(PAT)을 쓴다면 비공개 레포 접근을 위해 repo 스코프가 필요하지만, 범위가 넓으므로 가급적 fine-grained 토큰으로 해당 레포에만 read 권한을 주는 방식을 권합니다. 발급한 토큰은 Dockhand의 Git 자격 증명(credentials)에 등록하고, 스택 생성 시 그 자격 증명을 연결합니다.

컨테이너 레지스트리 연동 (image pull용)

CI가 ghcr.io에 push한 이미지를 Dockhand가 배포 시점에 내려받으려면, 레지스트리 인증이 필요합니다. 비공개 패키지라면 반드시 등록해야 하는 부분입니다.

ghcr.io에서 이미지를 pull하기 위한 토큰은 클래식 PAT + read:packages 스코프면 됩니다.

  • Token type: Personal access token (classic)
  • Scope: read:packages (이미지 다운로드 전용)
ghcr.io의 패키지 read/write는 현재 classic 토큰의 read:packages / write:packages 스코프로 다루는 것이 일반적입니다. CI가 push할 때 쓰는 토큰(write:packages 또는 워크플로의 GITHUB_TOKEN + packages: write)과, Dockhand가 pull할 때 쓰는 토큰(read:packages)을 역할별로 분리하는 것이 안전합니다. Dockhand에는 다운로드만 필요하므로 read 권한만 부여합니다.

발급한 레지스트리 토큰은 Dockhand의 레지스트리 자격 증명에 등록해 두면, 배포 시 image pull이 인증을 통해 정상적으로 동작합니다.

정리하면 토큰은 다음과 같이 최소 권한으로 분리하는 것이 핵심입니다.

용도 사용 주체 토큰 종류 권한
compose pull Dockhand Fine-grained PAT Contents: Read
image pull Dockhand Classic PAT read:packages

Dockhand Git Stack 설정

Dockhand에서 새 스택을 만들 때 타입을 Git으로 선택하고, 앞서 등록한 Git 자격 증명과 저장소 URL을 입력하면 됩니다. Git 스택은 저장소에서 배포되며 webhook 기반 재배포를 지원합니다. 동작 방식은 Dockhand가 저장소를 pull해 compose 파일을 배포하고 모니터링하며, 저장소의 변경 사항을 스케줄 또는 webhook으로 반영하는 구조입니다. (참고로 HTTPS Git 저장소는 v1.0.23부터 지원됩니다.)

여기서 GreedyLabs의 구조에 특히 중요한 설정이 하나 있습니다. CI가 이미지를 빌드해 고정 태그(예: :latest)로 push하는 경우입니다. 공식 매뉴얼은 이 상황을 다음과 같이 안내합니다. CI 파이프라인이 고정 태그로 이미지를 레지스트리에 push한 뒤 webhook으로 Dockhand를 트리거하는 경우, "Re-pull images"와 "Force redeployment"를 모두 활성화해야 하며, 이렇게 하면 compose 파일이 git에서 바뀌지 않았더라도 갓 push된 이미지를 항상 받아 재배포합니다.

이 두 옵션을 켜지 않으면, Dockhand의 지능형 배포가 "변경 사항이 없다"고 판단해 재배포를 건너뜁니다. 이미지만 교체된 상황에서 빠지기 쉬운 함정이므로 반드시 확인하시기 바랍니다.

이 "지능형 배포"의 판단 기준도 알아둘 만합니다. Dockhand는 스택의 compose 디렉터리 안에서 파일이 변경됐는지를 git diff로 검사하며, 여러 스택이 같은 저장소를 서로 다른 compose 경로로 사용할 수 있고 각 스택은 자신의 디렉터리가 변경될 때만 재배포됩니다. 모노레포로 여러 서비스를 관리할 때 불필요한 재배포를 막아주는 유용한 특성입니다.

GitHub Actions: 빌드 → Packages → Webhook

Actions 워크플로는 이미지 빌드 → ghcr.io push → Dockhand webhook 호출 순으로 동작합니다. 여기서 webhook 호출은 반드시 secret으로 서명해서 보내야 합니다. URL만으로 트리거가 가능하면 누구든 그 엔드포인트를 호출해 재배포를 일으킬 수 있기 때문입니다.

이 서명 과정을 매번 직접 구현하면 번거롭기 때문에, GreedyLabs는 이를 재사용 가능한 GitHub Action으로 만들어 공개했습니다.

action-dockhand-webhook

GitHub - GreedyLabs/action-dockhand-webhook: Dockhand webhook with secret
Dockhand webhook with secret. Contribute to GreedyLabs/action-dockhand-webhook development by creating an account on GitHub.

"action-dockhand-webhook"은 CI가 새 이미지를 빌드·push한 직후 Dockhand git-stack webhook을 트리거해 스택을 재배포하는 Action입니다. 핵심은 요청을 HMAC-SHA256으로 서명(X-Hub-Signature-256) 한다는 점으로, 시크릿이 URL에 노출되거나 로그에 남지 않습니다.

가장 단순한 사용법은 다음과 같습니다.

- uses: GreedyLabs/action-dockhand-webhook@v1
  with:
    url: ${{ secrets.DOCKHAND_WEBHOOK_URL }}
    secret: ${{ secrets.DOCKHAND_WEBHOOK_SECRET }}

빌드부터 배포까지 이어지는 전체 워크플로 예시는 다음과 같습니다.

name: Build and Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

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

      - name: Log in to GHCR
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository_owner }}/my-app:latest

      - name: Trigger Dockhand redeploy
        uses: GreedyLabs/action-dockhand-webhook@v1
        with:
          url: ${{ secrets.DOCKHAND_WEBHOOK_URL }}
          secret: ${{ secrets.DOCKHAND_WEBHOOK_SECRET }}

Action의 입력값은 다음과 같습니다.

Input 필수 기본값 설명
url Dockhand 스택 webhook URL (.../api/git/stacks/<id>/webhook)
secret HMAC-SHA256 키로 사용할 webhook 시크릿

출력값으로는 response가 있으며, Dockhand의 원응답 본문(예: {"success":true} 또는 변경이 없을 때 {"success":true,"skipped":true})을 그대로 돌려줍니다.

webhook URL과 secret 발급

webhook 자격 증명은 Dockhand에서 발급합니다. 절차는 다음과 같습니다.

  • Dockhand에서 트리거할 git stack을 엽니다.
  • 스택의 webhook을 활성화하고, 생성된 URL(스택 id 포함)과 secret을 복사합니다.
  • 이 둘을 GitHub Secrets에 각각 DOCKHAND_WEBHOOK_URL, DOCKHAND_WEBHOOK_SECRET으로 저장합니다.

이렇게 하면 webhook URL과 시크릿 모두 평문으로 워크플로에 노출되지 않고, Actions 실행 시점에만 주입됩니다.

Webhook 배포가 동작하는 방식

모든 요소가 맞물려 동작하는 순간을 순서대로 정리하면 다음과 같습니다.

sequenceDiagram
  participant Dev as 개발자
  participant GH as GitHub Actions
  participant GHCR as ghcr.io
  participant DH as Dockhand
  participant Docker as Docker (greedylabs)

  Dev->>GH: main 브랜치 push
  GH->>GH: 이미지 빌드
  GH->>GHCR: :latest 태그로 push
  GH->>DH: webhook 호출 (HMAC-SHA256 서명)
  DH->>DH: 서명 검증 + Git 저장소 pull
  DH->>GHCR: 새 이미지 re-pull (read:packages)
  DH->>Docker: 컨테이너 재생성 (force redeploy)
  Docker-->>DH: 배포 완료

한 가지 알아둘 점은, Dockhand가 마지막 sync 이후 새 커밋이 있을 때만 재배포한다는 것입니다. 변경이 없으면 {"success":true,"skipped":true}를 반환하고 컨테이너는 재시작되지 않습니다. 다만 앞서 말한 것처럼 :latest 같은 moving tag를 쓰면서 compose 파일이 git에서 바뀌지 않은 경우에는, 스택에서 Re-pull imagesForce redeployment를 켜둬야 webhook이 새 이미지를 받아 재배포합니다.

덧붙이면, --force-recreate 플래그는 compose 파일이 바뀌지 않았더라도 컨테이너를 재생성하게 하며, 이는 .env나 추가 env 파일의 환경 변수만 변경된 경우처럼 Docker Compose가 변화를 감지하지 못하는 상황에서 필요합니다. 환경 변수만 수정했는데 반영되지 않는다면 바로 이 지점을 의심하시면 됩니다.

멀티 노드로 확장하기

지금까지는 단일 VM(greedylabs) 안의 이야기였지만, Dockhand는 처음부터 멀티 환경을 염두에 둔 도구입니다. 한 대시보드에서 여러 Docker 호스트를 노드별로 관리할 수 있어, 인프라가 늘어나도 같은 GitOps 흐름을 그대로 확장할 수 있습니다.

원격 노드를 붙이는 방식은 크게 두 가지입니다.

  • 직접 연결(Direct): 원격 Docker 호스트에 TCP/HTTPS로 직접 연결하는 방식입니다. 노드가 Dockhand에서 네트워크로 직접 도달 가능할 때 적합합니다.
  • 에이전트(Hawser): Hawser는 Docker API를 직접 노출하지 않고도 원격 관리를 가능하게 해주는 경량 에이전트입니다. 특히 NAT나 방화벽 뒤에 있는 호스트를 위한 Edge 모드를 지원해, 인바운드 포트를 열지 않고도 노드를 붙일 수 있습니다. GreedyLabs처럼 Cloudflare Tunnel로 인바운드를 닫아둔 환경 철학과도 잘 맞는 방식입니다.
한 가지 주의할 점은 볼륨 경로 처리입니다. 원격 직접 연결(TCP/HTTPS) 환경에서는 스택 파일이 Dockhand 호스트에만 존재하므로, 상대 경로 볼륨 마운트의 경로 매칭이나 자동 변환이 동작하지 않습니다. 상대 경로 볼륨을 쓰는 원격 환경이라면, 배포 전에 파일을 원격 호스트로 전송해주는 Hawser 에이전트를 사용하는 편이 안전합니다.

즉, 지금은 단일 노드지만 향후 두 번째 VM이나 별도 물리 서버를 추가하더라도, 새 노드를 환경으로 등록하고 동일한 git stack + webhook 패턴을 얹기만 하면 됩니다. GitOps 구조 자체를 다시 설계할 필요가 없다는 점이 장기 운영에서 큰 장점입니다.

운영하면서 느낀 점

실제로 한동안 운영하며 체감한 변화들을 정리합니다.

  • 기억에 의존하던 관리에서 Git 기반 관리로: 이제 "이 서버에 무엇이 어떤 버전으로 떠 있는가"의 답이 항상 Git 저장소에 있습니다. 서버에 들어가 직접 확인하던 습관이 사라졌습니다.
  • 평문 시크릿에서 암호화 저장소로: 흩어져 있던 .env 평문 비밀들을 Dockhand의 암호화 시크릿으로 옮기면서, 백업과 형상 관리에서 민감 값을 분리할 수 있었습니다. 마음의 부담이 줄어든 변화였습니다.
  • 롤백이 git revert로 수렴: 배포에 문제가 생기면 커밋을 되돌려 다시 push하는 것으로 정리됩니다. 절차가 명확해졌습니다.
  • 서명된 webhook의 안정감: 직접 만든 Action으로 HMAC 서명을 표준화하니, 재배포 엔드포인트가 외부에 노출되어도 서명 없는 요청은 거부된다는 안정감이 있었습니다.
  • 고정 태그의 함정 주의: 앞서 언급한 Re-pull / Force redeployment 옵션을 몰랐다면 "빌드는 됐는데 왜 반영이 안 되지"라며 한참을 헤맸을 것입니다. 동일한 구조를 구축하시는 분들이라면 이 부분을 꼭 먼저 확인하시길 권합니다.
  • 단일 호스트에 적정한 무게: ArgoCD를 도입했다면 운영 비용이 배보다 배꼽이 컸을 텐데, Dockhand는 GreedyLabs 규모에 정확히 맞아떨어졌습니다.

마치며

거창한 GitOps 플랫폼 없이도, 단일 Docker 호스트에서 충분히 실용적인 GitOps를 구성할 수 있었습니다. 핵심은 책임 분리였습니다. CI는 GitHub Actions가 이미지를, CD는 Dockhand가 배포를 맡고, 그 사이를 서명된 webhook 하나가 잇는 구조입니다. 여기에 compose는 git에, 시크릿은 암호화 저장소에 두어 형상 관리와 비밀 관리를 동시에 정리할 수 있었습니다.

Kubernetes를 도입하기엔 과하지만 수동 compose 관리와 평문 시크릿에는 지친, GreedyLabs와 비슷한 규모의 자체 호스팅 운영자분들께 이 구성이 하나의 참고 레퍼런스가 되기를 바랍니다. 멀티 노드 확장까지 같은 패턴으로 이어진다는 점에서, 지금의 단일 호스트 구성은 끝이 아니라 출발점에 가깝습니다. 궁금한 점이나 개선 아이디어가 있다면 언제든 의견 주시면 감사하겠습니다.