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ć.
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
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 na0.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.