janol.pl_

Runbook

Deploy w 15 minut: Docker + Caddy + GitHub Actions

Za każdym razem, gdy stawiam nową stronę na swoim VPS-ie, robię dokładnie te same kroki. W pewnym momencie spisałem je w jeden runbook, żeby nie klikać po omacku o północy. To jest ta procedura — sprawdzona na kilkunastu wdrożeniach, od statycznego bloga po aplikacje z bazą danych.

Założenie jest proste: kontener nasłuchuje wyłącznie na 127.0.0.1:<port>, a przed nim stoi Caddy, który robi terminację TLS i reverse_proxy. Obrazu nie buduję na serwerze — jest za słaby — tylko w GitHub Actions, skąd trafia na ghcr.io.

1. DNS w Cloudflare

Zanim cokolwiek postawię, kieruję domenę na serwer. W Cloudflare dodaję rekord A wskazujący na IP maszyny. Proxy (pomarańczowa chmurka) zostawiam wyłączone na czas pierwszego wystawienia certyfikatu — Caddy musi dostać się do Let's Encrypt bezpośrednio. Po weryfikacji można je włączyć.

Uwaga Jeśli proxy Cloudflare jest włączone od początku, Caddy nie dokończy wyzwania ACME i będziesz się zastanawiać, czemu HTTPS nie wstaje. To najczęstszy błąd na starcie.

2. Sekrety w repozytorium

Actions muszą się zalogować do rejestru i wypchnąć obraz. W ustawieniach repo dodaję sekrety dla logowania do ghcr.io oraz — jeśli redeploy leci przez SSH — klucz do serwera.

# sekrety, które podpina workflow
GHCR_TOKEN     # token z uprawnieniem write:packages
DEPLOY_SSH_KEY # klucz prywatny do root@vps
DEPLOY_HOST    # 57.129.101.181

3. Workflow, który buduje i wypycha

Cały cykl mieści się w jednym pliku .github/workflows/deploy.yml. Buduje obraz, taguje go SHA commita, wypycha do rejestru, a na końcu łączy się z serwerem i podmienia kontener.

name: deploy
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: janolek83
          password: ${{ secrets.GHCR_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/janolek83/moja-strona:latest

Krok deployu to zwykłe ssh, które pobiera świeży obraz i restartuje usługę. Trzymam to celowo prosto — żadnego Kubernetesa dla strony, którą odwiedza pięć osób i mój monitoring.

docker pull ghcr.io/janolek83/moja-strona:latest
docker stop moja-strona # || true — gdy pierwszy raz
docker rm   moja-strona
docker run -d --name moja-strona \
  --restart unless-stopped \
  -p 127.0.0.1:3003:8080 \
  ghcr.io/janolek83/moja-strona:latest
Wolny port Prowadzę listę zajętych portów w osobnym pliku. Nowa usługa dostaje kolejny wolny — u mnie akurat 3003. Bez tego prędzej czy później dwa kontenery pobiją się o ten sam port.

4. Caddyfile

Najprzyjemniejsza część. Caddy sam załatwia certyfikat i jego odnawianie. Cała konfiguracja dla nowej domeny to dosłownie trzy linijki:

moja-strona.pl {
    reverse_proxy 127.0.0.1:3003
}

Po caddy reload strona żyje pod HTTPS-em, a ja nie dotykam OpenSSL-a ani crona od Certbota. Jeśli aplikacja trzyma stan na dysku, montuję wolumen data/ i pamiętam o chown 1000:1000 — inaczej proces w kontenerze nie ma prawa zapisu i wszystko wygląda na zepsute bez sensownego błędu.


Czego się nauczyłem

  • Kontener na 127.0.0.1, nigdy na 0.0.0.0 — tylko Caddy ma być wystawiony na świat.
  • Budowanie w Actions oszczędza nerwy: serwer tylko pobiera gotowy artefakt.
  • Playwright i inne ciężkie zależności trzymaj w devDependencies, żeby nie puchł obraz produkcyjny.
  • Jeden plik workflow na repo. Kopiuj-wklej, zmień nazwę obrazu i port. Nuda to zaleta.
Najlepszy pipeline to taki, o którym zapominasz — bo po prostu działa.

W następnym wpisie rozwijam wątek, dlaczego nie buduję obrazów na samym VPS-ie — nawet gdy kusi, żeby zrobić szybkie docker build na miejscu.

← wszystkie wpisy