DevOps12 min czytania

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.

GitOpsDevOpsDockerCI/CDInfrastructureVPSGitHub

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:

git push → GitHub Actions buduje obraz → obraz ląduje w GHCR → Actions łączy się przez SSH z VPS → docker compose pull && up -d
Kilka decyzji, które podjąłem i dlaczego:

  1. Obraz budujemy w CI, nie na serwerze. Widziałem sporo konfiguracji, w których VPS robi git pull i docker build u 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.
  2. 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.
  3. Ź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:

SekretZawartość
DEPLOY_HOSTadres IP lub domena VPS-a
DEPLOY_PORTport SSH (sugeruję ustalić inny niż 22 ze względu na bezpieczeństwo, np. 12345)
DEPLOY_USERdeploy
DEPLOY_SSH_KEYpeł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 denied na sockecie Dockera. User deploy nie jest w grupie docker albo jest, ale sesja SSH wystartowała przed dodaniem do grupy. Członkostwo w grupach czyta się przy logowaniu — usermod -aG nie działa wstecz na otwarte sesje.
  • Deploy przeszedł, aplikacja stara. Klasyka z tagiem latest: docker compose up -d bez wcześniejszego pull uzna, że obraz już jest, i nic nie zrobi. Dlatego w skrypcie pull stoi zawsze jawnie przed up.
  • Pełny dysk po trzech miesiącach. Warstwy starych obrazów. Jeśli nie masz prune w pipeline, dołóż go teraz — a do tego raz w tygodniu z crona docker system prune -af --volumes=false jako siatka bezpieczeństwa. Flagę --volumes zostaw w spokoju, chyba że lubisz odtwarzać bazy z backupu.
  • Sekrety w repo. Plik .env commitowany „tymczasowo, żeby zadziałało". Wpisz go do .gitignore zanim powstanie, a w repo trzymaj .env.example z 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.

Newsletter

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.

Zapisując się, akceptujesz przetwarzanie danych zgodnie z Polityką prywatności.

Czytaj dalej

Powiązane wpisy

DevOps8 min

GitOps na VPS: przewidywalne wdrożenia bez wielkiej platformy

Nie potrzebujesz klastra zarządzanego przez zespół lub zewnętrznego dostawcę, żeby mieć dobry i stabilny proces wdrażania. GitOps działa też na pojedynczym VPS — i może mieć większą wartość, niż się wydaje.

GitOpsDevOpsDocker
Współpraca

Ten temat dotyczy Twojego systemu?

Jeśli mierzysz się z podobnym problemem w praktyce — napisz. Spojrzę na konkret i podpowiem kierunek.