GitOps na VPS w praktyce: automatyczny deploy z Docker Compose i GitHub Actions (krok po kroku)
Automatyczny deploy na VPS z Docker Compose i GitHub Actions — kompletna konfiguracja krok po kroku. Od pustego serwera do wdrożeń uruchamianych przez git push, bez Kubernetesa i bez klikania po serwerze.
W poprzednim wpisie pisałem, dlaczego GitOps na pojedynczym VPS ma sens i czemu nie potrzebujesz do tego Kubernetesa. Ten artykuł to część praktyczna: przechodzimy od pustego serwera do stanu, w którym git push na main kończy się działającą nową wersją aplikacji na produkcji. Bez ręcznego SSH, bez „zaraz wejdę i ustawię", bez wdrożeń, które wyglądają inaczej za każdym razem.
Całość opiera się na trzech elementach, które prawdopodobnie już masz: repozytorium na GitHubie, VPS z Linuksem i aplikacji, która działa w kontenerze. Konfiguracja zajmuje wieczór. Potem przestajesz o niej myśleć — i to jest właściwie cały sens.
Co budujemy
Przepływ jest prosty i przedstawiony na poniższym diagramie:

Kilka decyzji, które podjąłem i dlaczego:
- Obraz budujemy w CI, nie na serwerze. Widziałem sporo konfiguracji, w których VPS robi
git pullidocker buildu siebie. Działa, dopóki build nie zje całego RAM-u na maszynie za 30 zł i nie położy przy okazji produkcji. Serwer ma jedno zadanie: uruchamiać kontenery. Budowanie zostaje w GitHub Actions, gdzie masz 7 GB RAM za darmo. - Rejestrem jest GHCR (GitHub Container Registry). Zero dodatkowej konfiguracji, autoryzacja tym samym tokenem co repo, darmowy dla obrazów prywatnych w rozsądnych limitach. Docker Hub w wersji darmowej ma limity pullowania, które prędzej czy później zaskoczą Cię w najgorszym momencie.
- Źródłem prawdy jest repozytorium.
docker-compose.yml, konfiguracja workflow, wersje obrazów — wszystko w Gicie. Stan serwera to pochodna repo, nie odwrotnie. Jeśli coś jest skonfigurowane tylko na serwerze, to znaczy, że tego nie ma.
Przygotowanie VPS-a
Zakładamy Ubuntu 24.04, ale różnice między dystrybucjami są kosmetyczne i nie mają znaczenia.
Najpierw dedykowany użytkownik do deployu. Nie root. Nie Twoje osobiste konto. Osobny user, który może dokładnie tyle, ile potrzebuje:
adduser --disabled-password deploy
usermod -aG docker deploy
Dopisanie do grupy docker to świadomy kompromis — członkostwo w tej grupie jest w praktyce równoważne rootowi na tej maszynie. Na pojedynczym VPS, gdzie i tak wszystko należy do Ciebie, akceptuję to ryzyko w zamian za prostotę. Alternatywą jest rootless Docker, ale to temat na osobny wpis.
Docker i Compose bierzemy z oficjalnego repozytorium (wersje z apt w Ubuntu bywają "legacy"):
curl -fsSL https://get.docker.com | sh
docker compose version # sanity check — ma być v2.x
Struktura katalogów: jeden katalog na aplikację, w środku docker-compose.yml i plik .env z sekretami runtime'owymi:
mkdir -p /srv/apps/moja-aplikacja
chown -R deploy:deploy /srv/apps
Jeśli hostujesz kilka aplikacji na jednej maszynie, każda dostaje swój katalog i swój plik compose. Brzmi banalnie, ale ta separacja ratuje życie, kiedy np. o 1 w nocy chcesz zrestartować jedną usługę i mieć pewność, że nie dotykasz pozostałych.
Na koniec minimalna "higiena" SSH w /etc/ssh/sshd_config:
PasswordAuthentication no
PermitRootLogin no
To nie jest kompletny hardening serwera — to absolutne minimum, poniżej którego nie radzę schodzić. Fail2ban, firewall i resztę dokładasz według własnych potrzeb (według mnie obowiązkowo). Koniecznie pamiętaj o loggingu i monitorowaniu VPS oraz jego logów (np. Netdata).
Klucze i sekrety
To jest miejsce, w którym większość tutoriali idzie na skróty, a potem klucz z pełnym dostępem do serwera leży w trzech miejscach naraz.
Zasada: para kluczy służy tylko do deployu i niczego więcej. Generujesz ją lokalnie, klucz prywatny trafia wyłącznie do GitHub Secrets, publiczny na serwer:
ssh-keygen -t ed25519 -C "deploy@moja-aplikacja" -f ./deploy_key -N ""
ssh-copy-id -i ./deploy_key.pub deploy@twoj-vps.example.com
Po skopiowaniu klucza publicznego usuń oba pliki z dysku. Klucz prywatny ma istnieć w jednym miejscu: Settings → Secrets and variables → Actions w repozytorium. Potrzebujesz trzech sekretów:
| Sekret | Zawartość |
|---|---|
DEPLOY_HOST | adres IP lub domena VPS-a |
DEPLOY_PORT | port SSH (sugeruję ustalić inny niż 22 ze względu na bezpieczeństwo, np. 12345) |
DEPLOY_USER | deploy |
DEPLOY_SSH_KEY | pełna zawartość klucza prywatnego |
Sekrety aplikacji (connection stringi, klucze API) to inna kategoria — one żyją w pliku .env na serwerze, obok docker-compose.yml. Nie w repo, nie w obrazie, nie w workflow. Compose podciągnie je sam. Jeśli kiedyś zmienisz hasło do bazy, edytujesz jeden plik na serwerze i restartujesz kontener — bez rebuildu, bez commita.
Workflow GitHub Actions
Zamieszczam pełny plik, nie fragmenty. Wrzucasz do .github/workflows/deploy.yml, podmieniasz nazwę obrazu i katalog — i to działa:
name: Deploy
on:
push:
branches: [main]
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /srv/apps/moja-aplikacja
docker compose pull
docker compose up -d
docker image prune -f
Dwie rzeczy, na które warto zwrócić uwagę.
Każdy obraz dostaje dwa tagi: latest i pełny SHA commita. Ten drugi wygląda na nadmiarowy dokładnie do momentu, w którym musisz się cofnąć do konkretnej wersji sprzed trzech dni. Wtedy przestaje. docker image prune -f na końcu to nie kosmetyka. Każdy deploy zostawia po sobie stary obraz i na VPS-ie z dyskiem 40 GB kwestia zapełnienia go warstwami Dockera to pytanie „kiedy", nie „czy". Sprzątamy od razu, w ramach tego samego wdrożenia.
Sam docker-compose.yml na serwerze jest krótki:
services:
app:
image: ghcr.io/twoj-user/moja-aplikacja:latest
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:8080:8080"
Port bindowany na 127.0.0.1 — na świat wystawia go dopiero reverse proxy (nginx, Caddy, Traefik — co wolisz). Aplikacja nigdy nie rozmawia z internetem bezpośrednio.
Jeśli obraz jest prywatny, serwer musi się raz zalogować do GHCR. Wygeneruj classic PAT z uprawnieniem read:packages i na serwerze wykonaj docker login ghcr.io -u twoj-user. Jednorazowo — credential zostaje zapisany.
Rollback i zero downtime
Najpierw rollback, bo to on jest ważniejszy. W lotnictwie nie improwizuje się procedur awaryjnych w trakcie awarii — są przećwiczone wcześniej. Z wdrożeniami jest identycznie: procedura powrotu (awarii) musi istnieć, zanim będzie potrzebna.
W tym setupie rollback to revert commita:
git revert HEAD
git push
Pipeline buduje poprzednią wersję kodu i wdraża ją tak samo, jak każdą inną. Żadnych ręcznych operacji na serwerze, żadnego „chyba tak to było skonfigurowane". Historia w Gicie pokazuje dokładnie co, kiedy i dlaczego wróciło. A jeśli build trwa za długo jak na sytuację awaryjną — masz przecież obrazy tagowane SHA: podmieniasz tag w compose na konkretny commit i robisz docker compose up -d. Dwie minuty.
Co z przerwą w działaniu podczas wdrożenia? docker compose up -d zatrzymuje stary kontener i uruchamia nowy, co przy typowej aplikacji oznacza kilka–kilkanaście sekund niedostępności. I teraz szczerze: dla większości systemów, które lądują na pojedynczym VPS, to jest w pełni akceptowalne. Wdrażasz o sensownej porze i nikt tego nie zauważa.
Jeśli te kilka sekund naprawdę Cię boli, najprostszym uzupełnieniem jest docker-rollout — plugin, który robi rolling update na poziomie Compose: stawia nowy kontener obok starego, czeka na healthcheck, przepina, gasi stary. Warunek: aplikacja musi mieć zdefiniowany healthcheck w compose i znieść chwilę pracy w dwóch instancjach naraz. Zanim po niego sięgniesz, policz, ile te sekundy przerwy faktycznie kosztują — często odpowiedź brzmi „nic".
Typowe błędy
Kolekcja typowych błędów, z "życia wzięte" — moja własna i z systemów, które przejmowałem.
permission deniedna sockecie Dockera. Userdeploynie jest w grupiedockeralbo jest, ale sesja SSH wystartowała przed dodaniem do grupy. Członkostwo w grupach czyta się przy logowaniu —usermod -aGnie działa wstecz na otwarte sesje.- Deploy przeszedł, aplikacja stara. Klasyka z tagiem
latest:docker compose up -dbez wcześniejszegopulluzna, że obraz już jest, i nic nie zrobi. Dlatego w skrypciepullstoi zawsze jawnie przedup. - Pełny dysk po trzech miesiącach. Warstwy starych obrazów. Jeśli nie masz
prunew pipeline, dołóż go teraz — a do tego raz w tygodniu z cronadocker system prune -af --volumes=falsejako siatka bezpieczeństwa. Flagę--volumeszostaw w spokoju, chyba że lubisz odtwarzać bazy z backupu. - Sekrety w repo. Plik
.envcommitowany „tymczasowo, żeby zadziałało". Wpisz go do.gitignorezanim powstanie, a w repo trzymaj.env.examplez samymi nazwami zmiennych. Jeśli sekret kiedykolwiek trafił do historii Gita — rotujesz go, nie usuwasz commit. Historia pamięta. - Workflow wisi na kroku SSH. W dziewięciu przypadkach na dziesięć to firewall — VPS nie wpuszcza połączeń z zewnątrz na port 22 albo hostingodawca ma osobną zaporę w panelu, o której nikt nie pamięta. GitHub Actions łączy się z publicznych adresów, które się zmieniają, więc whitelistowanie IP nie jest opcją; zabezpieczeniem jest klucz, nie adres.
Kiedy ten setup przestaje wystarczać
Uczciwie: ten proces ma swoje granice i warto je znać z góry, a nie odkrywać w bólach.
Pojedynczy VPS to pojedynczy punkt awarii — wysokiej dostępności z tego nie wyciśniesz. Gdy dochodzą osobne środowiska staging/produkcja z promocją między nimi, gdy nad wdrożeniami pracuje kilka osób i potrzebujecie approvali, gdy pojawia się audyt i wymagania compliance — zaczyna brakować warstwy, którą dają narzędzia typu ArgoCD czy Flux na prawdziwym klastrze.
Ale to są problemy, które sygnalizują się same i z wyprzedzeniem. Dopóki ich nie masz, każda dodatkowa warstwa platformy to koszt bez zwrotu: więcej ruchomych części, więcej rzeczy do aktualizowania, więcej powierzchni na awarie. Prosty proces, który rozumiesz w całości, wygrywa z imponującym, który rozumiesz w połowie. Za każdym razem.
FAQ
Czy potrzebuję Kubernetesa, żeby robić GitOps?
Nie. GitOps to praktyka — repozytorium jako źródło prawdy i automatyczne doprowadzanie środowiska do zadeklarowanego stanu — a nie konkretna technologia. Docker Compose i workflow z tego wpisu realizują ją w zupełności na skali jednego serwera.
Czy deploy przez SSH z GitHub Actions jest bezpieczny?
Przy zachowaniu zasad z tego wpisu — tak: dedykowany klucz tylko do deployu, przechowywany wyłącznie w GitHub Secrets, osobny użytkownik na serwerze, wyłączone logowanie hasłem. Największym realnym ryzykiem nie jest sam kanał SSH, tylko dostęp do repozytorium — kto może mergować do main, ten może wdrażać. Branch protection załatwia temat.
Ile to kosztuje?
Poza samym VPS-em — nic. GitHub Actions ma 2000 darmowych minut miesięcznie dla repozytoriów prywatnych (publiczne bez limitu), a GHCR nie liczy sobie za obrazy w typowych rozmiarach. Deploy z tego wpisu zamyka się w 2–4 minutach, więc darmowa pula starcza na dziesiątki wdrożeń miesięcznie.
Co z bazą danych i migracjami?
Baza działa jako osobna usługa w tym samym pliku compose, z wolumenem na dane. Migracje najprościej odpalać przy starcie aplikacji albo jako osobny, jednorazowy kontener w skrypcie deployu — przed up -d właściwej usługi. To zasługuje na osobny wpis, bo diabeł tkwi w migracjach niekompatybilnych wstecz.
Masz teraz komplet: przygotowany serwer, bezpieczną wymianę kluczy, działający pipeline i procedurę powrotu na wypadek, gdyby coś poszło nie tak. Jeżeli chcesz zrozumieć, dlaczego ten model ma przewagę nad ręcznymi wdrożeniami i półautomatami — wróć do pierwszej części o GitOps na VPS, gdzie rozkładam to na czynniki pierwsze.
Konkrety o softwarze, architekturze i DevOps — podzielę się z Tobą swoim doświadczeniem
Nieregularnie i bez spamu. Prosto na Twojego maila. Tylko wartościowe teksty, wnioski z produkcji i praktyczne materiały. Wypiszesz się jednym kliknięciem, kiedy zechcesz.