Jak zbudować pipeline CI/CD w GitHub Actions dla aplikacji w Dockerze od commitów po produkcję

0
30
3/5 - (1 vote)

Nawigacja:

Od pomysłu do pierwszego pipeline’u – założenia i zakres

Celem jest zbudowanie prostego, niedrogiego, a jednocześnie solidnego pipeline’u CI/CD w GitHub Actions dla aplikacji w Dockerze. Od momentu commitu w repozytorium, przez automatyczne testy, budowanie obrazu i wysłanie go do rejestru, aż po wdrożenie kontenera na działający serwer produkcyjny.

Dla małego lub średniego projektu CI/CD nie jest luksusem, tylko sposobem na uniknięcie ręcznych, powtarzalnych zadań, które generują błędy. Jeden commit powinien w przewidywalny sposób przejść przez testy, zbudować obraz Dockera, a na końcu zaktualizować działającą aplikację bez ręcznego logowania się na serwer i przepisywania komend.

Zakres narzędzi i rozwiązań wykorzystywanych w pipeline

Całe rozwiązanie opiera się na kilku elementach, które dobrze ze sobą współpracują i są dostępne bez dodatkowych, wysokich opłat:

  • repozytorium kodu na GitHubie,
  • aplikacja zapakowana w obraz Dockera,
  • GitHub Actions jako system CI/CD,
  • zewnętrzny rejestr obrazów (Docker Hub lub GitHub Container Registry),
  • prosty serwer (najczęściej VPS) z zainstalowanym Dockerem i ewentualnie docker-compose.

Taki zestaw pozwala zachować kontrolę nad kosztami, a jednocześnie nie blokuje późniejszej migracji na bardziej zaawansowaną infrastrukturę (Kubernetes, managed container services). Logika pipeline’u zostaje w GitHub Actions, a nie w jednym specyficznym dostawcy chmury.

Priorytety budżetowego pipeline’u CI/CD

Projektując pipeline CI/CD jako „budżetowy pragmatyk”, warto trzymać się kilku zasad:

  • Podstawowe bezpieczeństwo – sensowne zarządzanie sekretami, brak haseł w repo, minimalne uprawnienia.
  • Mało kroków ręcznych – człowiek ma podejmować decyzje (np. zatwierdzić deploy na produkcję), a nie przepisywać komendy z notatnika.
  • Kontrola kosztów – maksymalne wykorzystanie darmowego planu GitHub Actions i taniego hostingu kontenerów (VPS lub prosty PaaS).
  • Minimalny, ale kompletny zestaw funkcji – testy, build obrazu, push do rejestru, deploy na serwer.

Minimalny zakres funkcji pipeline’u CI/CD

Praktyczny pipeline CI/CD dla aplikacji w Dockerze powinien ogarniać przynajmniej:

  1. Testy aplikacji – uruchamiane przy każdym commicie lub pull requeście; jeśli testy padają, dalej nic się nie dzieje.
  2. Budowanie obrazu Dockera – z kodu, który przeszedł testy; najlepiej z deterministycznym Dockerfile.
  3. Tagowanie i push do rejestru – obraz trafia do Docker Hub lub GHCR z jednoznacznym tagiem (wersja, SHA).
  4. Automatyczny deploy – aktualizacja kontenera na serwerze staging/produkcja wywołana z workflow.

Dopiero na takim fundamencie sens mają dodatki: blue/green deployment, canary, smoke testy po deployu czy skanowanie bezpieczeństwa obrazów. Najpierw warto zbudować stabilny, tani trzon.

Przygotowanie projektu pod CI/CD w Dockerze

Zanim pojawi się pierwszy workflow, repozytorium i sam projekt muszą być przygotowane do konteneryzacji i automatycznych buildów. Poprawne uporządkowanie na tym etapie zaoszczędzi sporo czasu przy każdej zmianie pipeline’u.

Struktura repozytorium pod pipeline CI/CD

Typowe, proste repozytorium aplikacji API (np. Node.js, Python czy Go) może wyglądać tak:

.
├── src/
│   ├── main.go        # lub app.py / index.js
│   └── ...
├── tests/
│   └── ...
├── Dockerfile
├── docker-compose.yml (opcjonalnie)
├── .github/
│   └── workflows/
│       └── ci-cd.yml  # workflow GitHub Actions
├── .env.example
├── README.md
└── package.json / go.mod / pyproject.toml

Kluczowe elementy z perspektywy CI/CD to:

  • Dockerfile w katalogu głównym (lub jasno określona ścieżka),
  • katalog .github/workflows – tam będą pliki YAML z workflowami,
  • .env.example – pokazuje, jakich zmiennych środowiskowych oczekuje aplikacja, ale bez wrażliwych danych,
  • osobny katalog na testy – ułatwia jednolite uruchamianie testów w CI.

Minimalny Dockerfile produkcyjny

Dockerfile tworzony „do devu” często nie nadaje się do produkcji, bo jest za ciężki lub niedostatecznie powtarzalny. Przykład prostego, ale sensownego Dockerfile dla Node.js:

# Etap build (opcjonalnie, przy większych projektach)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Etap runtime
FROM node:20-alpine
WORKDIR /app

# Kopiujemy tylko to, co potrzebne
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --only=production --omit=dev

ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

Kluczowe elementy:

  • Base image w wersji slim/alpine – lżejszy obraz, tańszy transfer, szybsze deploye.
  • Podział na etapy – dependency install + build i runtime w osobnej warstwie.
  • Brak dev-dependencies w finalnym obrazie.
  • ENTRYPOINT / CMD ustawione tak, by kontener po prostu startował serwer.

W Go można iść jeszcze dalej i budować statyczny binarny plik w etapie build, a potem w runtime wykorzystywać goły obraz scratch lub distroless. Zyskujemy ekstremalnie lekki obraz i mniejszą powierzchnię ataku.

Oddzielenie konfiguracji środowiskowej od kodu

Pipeline CI/CD będzie operował na różnych środowiskach (staging, produkcja), dlatego konfiguracja nie może być „wpalona” w kod. Minimalny zestaw praktyk:

  • używanie zmiennych środowiskowych zamiast stałych w kodzie (np. process.env.DB_URL),
  • lokalne pliki .env ignorowane przez Git (.gitignore),
  • w repozytorium tylko .env.example z nazwami zmiennych bez wrażliwych wartości,
  • na serwerze staging/produkcja własne .env lub konfiguracja w systemd/docker-compose.

W pipeline CI/CD sekrety (hasła do bazy, klucze API, tokeny do rejestru) są trzymane w GitHub Secrets / Environment Secrets, a do kontenera trafiają jako zmienne środowiskowe lub pliki konfiguracyjne generowane w czasie deployu.

Przykładowa aplikacja jako punkt odniesienia

Przydatny jest prosty model: np. API w Node.js z kilkoma endpointami i testami w Jest. Struktura:

src/
  index.js
  routes.js
tests/
  routes.test.js
package.json
Dockerfile

W package.json jedna komenda testowa:

"scripts": {
  "test": "jest",
  "build": "tsc" // jeśli jest TypeScript; w prostym JS może być pominięte
}

Pipeline GitHub Actions będzie używał tych komend, więc muszą działać lokalnie. Jeśli lokalnie aplikacja się nie buduje albo testy nie przechodzą, CI też będzie padać – szkoda minut i czasu na debugowanie na ślepo.

Krótki przegląd GitHub Actions – jak działa i co jest za darmo

GitHub Actions jest wbudowany w samo repozytorium, nie wymaga oddzielnego serwera CI. Workflow zapisany w YAML jest odpalany na zewnętrznym runnerze GitHuba przy określonych zdarzeniach.

Podstawowe pojęcia: workflow, job, step, runner, event

  • Workflow – cały proces CI/CD opisany w pliku YAML w .github/workflows. Np. ci-cd.yml.
  • Job – logiczna jednostka w workflow, wykonywana na osobnym runnerze (np. ci, build_and_push, deploy).
  • Step – pojedynczy krok w jobie (uruchomienie akcji, komendy bash, logowanie do rejestru).
  • Runner – maszyna, na której wykonuje się job (GitHub-owy lub self-hosted). Na darmowym planie zazwyczaj używa się runnerów GitHuba (ubuntu-latest).
  • Event – zdarzenie, które uruchamia workflow, np. push, pull_request, release, workflow_dispatch.

Limity darmowego planu i jak nie przepalać minut

Na darmowym planie GitHub daje określoną liczbę minut CI miesięcznie (zależnie od typu konta: prywatne/publiczne, organizacja/osoba). Minuty najłatwiej „spalić” na:

  • częstych workflowach zbudowanych z ciężkich jobów (np. duże obrazy Dockera, brak cache),
  • odpalaniu pełnych buildów na wszystkie gałęzie, nawet te techniczne,
  • macierzach buildów, które testują każdą kombinację systemu i wersji języka, choć nie jest to potrzebne.

Aby ograniczyć zużycie:

  • włączać pełny pipeline (testy + build + deploy) tylko dla main i ewentualnie tagów,
  • dla feature branchy ograniczyć się do testów jednostkowych,
  • używać cache (np. zależności npm/go/pip),
  • nie budować obrazu Dockera, jeśli testy nie przeszły (needs w jobach).

Publiczne vs prywatne repozytoria pod kątem budżetu

W publicznych repozytoriach GitHub często oferuje większy limit minut CI za darmo niż w prywatnych. Przy małych projektach open-source może to być realna oszczędność. W prywatnych projektach trzeba:

  • bardziej pilnować, które gałęzie uruchamiają workflow,
  • unikać zbędnych macierzy i wielokrotnych buildów,
  • przemyśleć, kiedy pipeline ma się uruchamiać automatycznie, a kiedy z ręcznym triggerem (workflow_dispatch).

Czytanie logów workflow i diagnostyka

Przy pierwszych podejściach każdy pipeline będzie się kilka razy wywalał. Podstawowa umiejętność to szybkie odczytanie, na którym kroku i z jakiego powodu job się zatrzymał:

  • w zakładce Actions w repo wybierasz dany workflow run,
  • rozsuwasz konkretnego job, a potem step, który jest oznaczony na czerwono,
  • czytasz pełny log (często błąd jest na końcu natłoku informacji),
  • szukasz błędów typu: nieprawidłowa ścieżka, brak uprawnień, zła nazwa sekretu, błąd w Dockerfile.

Na starcie pomaga dodanie prostych kroków run: ls -R czy run: pwd w celu zrozumienia, w jakim katalogu działa runner i co jest faktycznie dostępne.

Duży przemysłowy rurociąg biegnący przez zielony las
Źródło: Pexels | Autor: Wolfgang Weiser

Pierwszy workflow CI – testy aplikacji przy każdym commicie

Najtańszy, ale kluczowy element pipeline’u to automatyczne testy. Nawet jeśli na początku jest tylko kilka testów jednostkowych, dobrze, by odpalały się przy każdym pushu i pull requeście.

Tworzenie minimalnego workflowa ci.yml

W repozytorium trzeba utworzyć plik .github/workflows/ci.yml z podstawową konfiguracją. Przykład dla aplikacji Node.js:

name: CI

on:
  push:
    branches:
      - main
      - develop
      - feature/**
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Taki workflow:

  • odpala się na push do określonych gałęzi i na pull_request do main,
  • wykonuje checkout kodu,
  • instaluje Node.js w zadanej wersji z cache npm,
  • instaluje zależności i uruchamia testy.

Dobór triggerów: gałęzie, PR, tagi

Przy testach warto mieć szeroki zakres triggerów, ale bez przesady:

  • push na main – absolutnie tak,
  • push na główne gałęzie developerskie (np. develop) – zazwyczaj tak,
  • feature branshe – można ograniczyć do tych, które zaczynają się np. od feature/,
  • pull_request do main – kluczowe, bo broni przed włączeniem błędnego kodu do głównej gałęzi.

Optymalizacja czasu trwania testów i cache w CI

Sam fakt, że testy się wykonują, to jedno. Drugie – jak szybko i za jakie „minuty” na GitHubie. Kilka prostych zabiegów mocno przycina czas joba:

  • unikanie instalacji zbędnych narzędzi (w Node – brak globalnych instalacji, używanie npm ci zamiast npm install),
  • czytelne rozdzielenie testów szybkich i wolnych (np. npm test vs npm run test:integration),
  • cache zależności i – jeśli ma sens – artefaktów builda.

W prostym projekcie Node można dołożyć cache ręcznie (ponad to, co robi setup-node) lub dla innych ekosystemów:

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

      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: |
            **/node_modules
          key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-modules-

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

W wielu małych projektach ten cache node_modules wystarczy, by testy na gałęziach featureowych kończyły się w kilkadziesiąt sekund zamiast kilku minut.

Oznaczanie statusu testów w PR i blokowanie merge

Sam pipeline niczego nie zabezpiecza, jeśli kod z czerwonym statusem CI i tak można włączyć do main. Warto podpiąć status workflow do branch protection rules:

  1. W ustawieniach repozytorium wejść w Branches → Branch protection rules.
  2. Dodać regułę dla main.
  3. Wymusić Require status checks to pass before merging i zaznaczyć workflow CI.

Efekt – dopóki testy nie przejdą, przycisk „Merge” w PR będzie wyszarzony. Dobra bariera przed pośpiechem i „a, później naprawimy”.

Budowanie i tagowanie obrazu Dockera w GitHub Actions

Po testach następuje etap, który zaczyna kosztować trochę więcej minut – budowanie obrazu. Jeśli robi się to rozsądnie i tylko wtedy, gdy ma to sens, rachunek nadal pozostaje akceptowalny.

Podstawowy job buildujący obraz z Dockerfile

Najprostszy wariant: w osobnym jobie, który zależy od testów, budowany jest obraz Dockera na bazie Dockerfile z repo:

name: CI & Docker Build

on:
  push:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  build_image:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: false
          tags: myapp:ci

Taki job obrazu jeszcze nigdzie nie wypycha, ale sprawdza, czy Dockerfile jest poprawny i obraz się w ogóle buduje. To dobry etap przejściowy, zanim wprowadzisz push do rejestru.

Strategia tagowania obrazów: SHA, branch, wersje

Nadawanie sensownych tagów od początku oszczędza bałaganów typu latest-2, fix, new. Szybki, praktyczny schemat:

  • main – tag z Gita (np. v1.3.0, gdy tworzysz release) + skrócony SHA,
  • gałęzie robocze – tag z nazwą gałęzi + SHA, trzymane raczej tylko w rejestrze pomocniczym,
  • produkcyjny alias – latest lub prod, wskazujący na bieżącą stabilną wersję.

W YAML można wygodnie budować tagi z kontekstu:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build_image:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=tag
            type=sha

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image (no push yet)
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: false
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

docker/metadata-action generuje sensowne tagi na podstawie gałęzi, tagów gitowych i SHA. Nie trzeba ich samodzielnie sklejać w Bashu.

Cache buildów Dockerowych

Budowanie obrazu przy każdym pushu do main z kompletnie zimnym cache może być drogie. Przy Docker Buildx można włączyć prosty cache oparty o GitHub Actions cache:

      - name: Build and cache image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: false
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Przy kolejnych buildach niektóre warstwy (szczególnie instalacja zależności) zostaną użyte ponownie. Mniej minut, szybszy feedback.

Integracja z rejestrem obrazów (Docker Hub lub GitHub Container Registry)

Sam obraz zbudowany w CI jest chwilowy. Żeby go wykorzystać przy deployu, trzeba go wypchnąć do rejestru. Dwa najprostsze wybory: Docker Hub (klasyk) i GitHub Container Registry (GHCR), który jest mocno spięty z GitHubem.

Konfiguracja sekretów do rejestru

Niezależnie od rejestru potrzebny jest login i hasło/token. W repozytorium w zakładce Settings → Secrets and variables → Actions dodaj:

  • REGISTRY_USERNAME – użytkownik Docker Hub lub właściciel GHCR,
  • REGISTRY_PASSWORD – token personal access lub hasło,
  • opcjonalnie REGISTRY – np. ghcr.io lub index.docker.io.

Takie sekrety nigdy nie pojawiają się jawnie w logach, o ile nie wyplujesz ich sam przez echo. To one wiążą pipeline z kontem w rejestrze.

Logowanie i push do Docker Hub

Dla Docker Huba podstawowy job z wypychaniem obrazu wygląda tak:

env:
  REGISTRY: docker.io
  IMAGE_NAME: ${{ secrets.REGISTRY_USERNAME }}/myapp

jobs:
  build_and_push:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=tag
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

To zazwyczaj wystarcza, by mieć w Docker Hub aktualny obraz dla main i ewentualnych tagów wersji.

Push do GitHub Container Registry (GHCR)

GHCR jest wygodny, bo używa tego samego konta co GitHub. Można użyć wbudowanego tokena GITHUB_TOKEN, ale w wielu wypadkach czytelniej jest skorzystać z osobnego PAT (szczególnie przy multi-repo).

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build_and_push_ghcr:
    needs: test
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

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

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=tag
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

permissions.packages: write jest kluczowe – bez tego GITHUB_TOKEN nie będzie mógł publikować obrazów w GHCR.

Ograniczanie ilości pushy, żeby nie przepalać zasobów

Push każdego obrazu z każdej gałęzi do rejestru robi śmietnik i marnuje transfer. Bezpieczny kompromis:

  • push obrazów tylko dla main i/lub tagów,
  • gałęzie featureowe – build tylko lokalnie w jobie bez push,
  • oddzielny workflow dla release/tagów (np. on: push: tags: ['v*']), który dodaje stabilne tagi.

Mały zespół z reguły nie potrzebuje pełnej historii obrazów z każdego commita. Wystarczy ciąg przejrzystych wersji i ewentualnie kilka ostatnich buildów z main.

Projektowanie etapów CD – flow od main do produkcji

Gdy budowanie i push obrazu już działają, można zacząć myśleć o tym, jak mapować gałęzie Gita na środowiska: staging, preprod, produkcję. Nie trzeba od razu trzech environmentów; nawet dwa (staging + produkcja) znacząco poprawiają bezpieczeństwo zmian.

Podział na środowiska i reguły przejścia

Przykładowy, prosty model:

  • develop lub staging – każde wypchnięcie do tej gałęzi uruchamia automatyczny deploy na serwer testowy,
  • main – zmiany trafiają tam zwykle z PR po review i uruchamiają mechanizm deployu produkcyjnego, ale z ręcznym potwierdzeniem.

W YAML można oddzielić te dwa scenariusze za pomocą warunków if lub dwóch osobnych workflow:

on:
  push:
    branches:
      - main
      - staging

jobs:
  deploy_staging:
    if: github.ref == 'refs/heads/staging'
    # ...

  deploy_production:
    if: github.ref == 'refs/heads/main'
    # ...

Dla produkcji często dobrze jest dodać warstwę ręcznego zatwierdzenia (GitHub Environments z required reviewers), szczególnie gdy deploy dotyczy klientów, a nie tylko wewnętrznego zespołu.

GitHub Environments i reguły zatwierdzania

GitHub udostępnia pojęcie Environment, w którym można przechowywać osobne sekrety i kontrolować kto może uruchomić deployment:

  1. W Settings → Environments utwórz np. staging i production.
  2. W każdym ustaw osobne sekrety, np. SSH_HOST, SSH_USER, ENV_FILE.
  3. Dla production włącz Required reviewers, by deploy wymagał kliknięcia „Approve and deploy”.

W workflow job można przypiąć environment:

jobs:
  deploy_production:
    needs: build_and_push
    runs-on: ubuntu-latest
    environment:
      name: production
    steps:
      # tu deploy, z dostępem do secrets.environment.*

Sekrety przypisane do environmentu są rozdzielone od globalnych i nie wychodzą poza joby, które explicit wskazują dane środowisko.

Warunki i manualne wyzwalanie deployów

Nie zawsze opłaca się odpalać deploy przy każdym pushu do main. W mniejszych projektach wygodny bywa tryb: automatyczny build i push obrazu + ręczny trigger deployu:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'production'
        type: choice
        options:
          - staging
          - production

Przy takim podejściu osoba odpowiedzialna za wydania wchodzi w zakładkę Actions, wybiera workflow i wyzwala go z odpowiednim parametrem. Budżetowo, ale z kontrolą.

Zardzewiałe rury i zawory przemysłowe w starej fabryce
Źródło: Pexels | Autor: Pixabay

Wdrażanie kontenera na serwerze – przykładowe rozwiązania „na budżecie”

Deployment nie wymaga od razu klastra Kubernetes i autoscalingu. Na początek wystarczy tani VPS, Docker i odrobina dyscypliny w utrzymaniu.

Scenariusz 1: VPS + Docker CLI po SSH

Najprostszy schemat:

  1. Mały VPS (np. 1–2 GB RAM) z zainstalowanym Dockerem.
  2. Na serwerze logowanie do rejestru (Docker Hub / GHCR) z użyciem tokena technicznego.
  3. Kontynuacja scenariusza 1: krok po kroku z GitHub Actions

    Do spięcia GitHuba z VPS-em wystarczy klucz SSH z ograniczonymi uprawnieniami i prosty skrypt, który na serwerze wykona docker pull i restart kontenera.

  1. Na serwerze wygeneruj użytkownika technicznego, np. deploy, dodaj go do grupy docker.
  2. Wygeneruj parę kluczy SSH (np. ssh-keygen -t ed25519), publiczny wrzuć do ~deploy/.ssh/authorized_keys, prywatny zapisz jako sekret w GitHubie (np. SSH_KEY).
  3. Upewnij się, że serwer ma już zalogowane docker login do twojego rejestru (token techniczny, nie ten z GitHuba).

Job deploymentowy może wyglądać tak:

jobs:
  deploy_staging:
    if: github.ref == 'refs/heads/staging'
    needs: build_and_push
    runs-on: ubuntu-latest
    environment:
      name: staging

    steps:
      - name: Prepare SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf "Host vps-stagingn  HostName %sn  User %sn  IdentityFile ~/.ssh/id_ed25519n  StrictHostKeyChecking non" 
            "${{ secrets.SSH_HOST }}" "${{ secrets.SSH_USER }}" >> ~/.ssh/config

      - name: Deploy container
        run: |
          ssh vps-staging << 'EOF'
          set -e
          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
          docker stop myapp || true
          docker rm myapp || true
          docker run -d --name myapp 
            -p 80:3000 
            --env-file /opt/myapp/.env 
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
          EOF

To jest minimalna wersja, ale już daje pełen przepływ: commit → testy → build → push → restart usługi na serwerze.

  • StrictHostKeyChecking no można później zastąpić prekonfiguracją known_hosts, żeby wyeliminować potencjalne MITM.
  • --env-file wskazuje na plik z konfiguracją na serwerze, więc pipeline nie musi znać sekretnych zmiennych.

Scenariusz 2: docker-compose na serwerze

Jeśli aplikacja ma więcej niż jeden kontener (np. backend + frontend + baza), wygodniej zarządzać stackiem przez docker-compose. Z perspektywy CI/CD zmienia się głównie to, że GitHub wywołuje na serwerze docker compose pull && docker compose up -d zamiast ręcznego docker run.

Przykładowy docker-compose.yaml na VPS-ie:

version: '3.9'

services:
  app:
    image: ghcr.io/my-org/myapp:main
    restart: always
    env_file:
      - /opt/myapp/.env
    ports:
      - "80:3000"
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    restart: always

Deploy w workflow może wyglądać tak:

jobs:
  deploy_with_compose:
    if: github.ref == 'refs/heads/main'
    needs: build_and_push_ghcr
    runs-on: ubuntu-latest
    environment:
      name: production

    steps:
      - name: Prepare SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519

      - name: Deploy via docker-compose
        run: |
          ssh -o StrictHostKeyChecking=no 
            -i ~/.ssh/id_ed25519 
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
          set -e
          cd /opt/myapp
          docker compose pull app
          docker compose up -d app
          docker image prune -f
          EOF

Taki wariant jest tani w utrzymaniu i nie wymaga grzebania przy parametrach docker run w CI. Zmiana portu, dodanie wolumenu czy sidecara – wszystko robi się w jednym pliku na serwerze.

Scenariusz 3: tani PaaS (Dokku / Coolify / CapRover)

Jeśli nie ma czasu na ręczne ogarnianie Dockerów na VPS-ie, sensowną opcją jest lekki PaaS, który stoi na jednym serwerze, ale udostępnia wygodny interfejs do aplikacji.

Przykładowe rozwiązania open source:

  • Dokku – najdłużej na rynku, prosty deployment przez git push lub obrazy Dockera.
  • Coolify – ładny panel WWW, integracja z repozytoriami, automatyczne SSL.
  • CapRover – UI w stylu mini-Heroku, też na jednym serwerze, z możliwością skali.

W każdym z nich można zredukować deployment z GitHuba do jednego webhooka lub komendy CLI, często bez pisania ręcznego SSH. Koszt sprzętowy ten sam (tani VPS), ale mniej własnego skryptowania.

Pełny przykład workflow CI/CD – od push do działającego kontenera

Składanie wszystkiego w całość wygląda groźnie, ale da się to ująć w jednym pliku .github/workflows/ci-cd.yml z kilkoma jobami. Poniżej przykład dla:

  • testów na każdym pushu,
  • build + push obrazu na GHCR dla main i staging,
  • automatycznego deployu na staging,
  • deployu na produkcję z ręcznym potwierdzeniem przez Environments.
name: CI/CD

on:
  push:
    branches:
      - main
      - staging
  pull_request:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

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

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install deps
        run: npm ci

      - name: Run tests
        run: npm test -- --ci

  build_and_push:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging'

    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

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

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy_staging:
    needs: build_and_push
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/staging'
    environment:
      name: staging

    steps:
      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.STAGING_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf "Host stagingn  HostName %sn  User %sn  IdentityFile ~/.ssh/id_ed25519n  StrictHostKeyChecking non" 
            "${{ secrets.STAGING_SSH_HOST }}" "${{ secrets.STAGING_SSH_USER }}" >> ~/.ssh/config

      - name: Deploy to staging
        run: |
          ssh staging << 'EOF'
          set -e
          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging
          docker stop myapp-staging || true
          docker rm myapp-staging || true
          docker run -d --name myapp-staging 
            -p 8080:3000 
            --env-file /opt/myapp-staging/.env 
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging
          EOF

  deploy_production:
    needs: build_and_push
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment:
      name: production

    steps:
      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf "Host prodn  HostName %sn  User %sn  IdentityFile ~/.ssh/id_ed25519n  StrictHostKeyChecking non" 
            "${{ secrets.PROD_SSH_HOST }}" "${{ secrets.PROD_SSH_USER }}" >> ~/.ssh/config

      - name: Deploy to production
        run: |
          ssh prod << 'EOF'
          set -e
          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
          docker stop myapp || true
          docker rm myapp || true
          docker run -d --name myapp 
            -p 80:3000 
            --env-file /opt/myapp/.env 
            --restart=always 
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
          EOF

Przy takim układzie mały zespół jest w stanie wdrażać bezpośrednio z Gita, a główna praca to pilnowanie sensownego Dockerfile i konfiguracji na serwerze. Inwestycja: jeden wieczór, efekt: koniec z ręcznym kopiowaniem plików i klikaniem FTP.

Dodanie workflow dla wersjonowania przez tagi

W miarę dojrzewania projektu opłaca się rozdzielić deployment ciągły (gałąź) od wydań wersjonowanych (tagi). Najprostszy krok: osobny workflow, który reaguje tylko na tagi i buduje obraz z wersją semantyczną.

name: Release

on:
  push:
    tags:
      - 'v*.*.*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build_and_push_release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

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

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=tag
            type=sha

      - name: Build and push release image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}

Obok bieżącego :main pojawiają się wtedy stabilne tagi typu :v1.3.0, które można podpiąć pod produkcję, gdy priorytetem jest powtarzalność i szybki rollback, a nie natychmiastowość wydań.

Bezpieczeństwo i zarządzanie sekretami w GitHub Actions

CI/CD bardzo szybko staje się miejscem, gdzie krążą hasła, klucze SSH, tokeny do rejestrów i API. Kilka rozsądnych zasad ogranicza ryzyko bez wprowadzania drogiej infrastruktury typu vault.

Rodzaje sekretów w GitHubie i jak ich używać

GitHub udostępnia kilka poziomów przechowywania sekretów:

  • Repository secrets – podstawowy poziom, widoczne dla wszystkich workflow w repozytorium.
  • Environment secrets – powiązane z environmentami (np. staging, production); dostępne tylko dla jobów przypiętych do danego environmentu.
  • Organization secrets – współdzielone między wieloma repozytoriami (np. wspólny klucz do prywatnego rejestru).

Najprostszy i sensowny układ dla małego projektu:

  • sekrety specyficzne dla środowiska (host, user, klucze) – jako environment secrets,
  • sekrety ogólne (np. REGISTRY_USERNAME) – jako sekrety repozytorium.

Unikanie wycieku sekretów w logach

GitHub maskuje wartości sekretów w logach, ale tylko gdy dokładnie odpowiadają zapisanej wartości. Jeśli zrobisz echo $SSH_KEY | base64, to już nie będzie zmaskowane. Kilka prostych nawyków pomaga utrzymać porządek:

  • nie logować pełnych komend, które mogą zawierać sekrety (np. nie wypisywać całych URL-i z wbudowanym loginem),
  • nie sklejać sekretów w nowe ciągi znaków, które potencjalnie trafią do logów,
  • ograniczyć użycie set -x w skryptach bash uruchamianych w CI.

Jeśli już coś poleci do logów, użyj funkcji „mask” w ustawieniach repozytorium oraz zresetuj dany sekret (klucz/tokenu i tak trzeba wygenerować nowy).

Minimalne uprawnienia tokena GITHUB_TOKEN

Domyślnie GitHub Actions udostępnia w jobach token GITHUB_TOKEN z dość szerokimi uprawnieniami. Lepiej je zawęzić na poziomie joba, zamiast polegać na domyślnych ustawieniach.

permissions:
  contents: read
  packages: write
  id-token: write

To przykładowe ustawienie dla joba budującego obraz i publikującego go w GHCR. Jeśli jakiś job nie musi niczego zapisywać w repozytorium ani w rejestrze, można faktycznie ustawić wszystkie uprawnienia na read albo całkowicie je pominąć, by niepotrzebnie nie otwierać powierzchni ataku.

Bezpieczne przechowywanie kluczy SSH

Klucze SSH używane przez CI lepiej traktować jako osobne, techniczne klucze, których kompromitacja nie daje atakującemu pełnego dostępu do konta developera.

Dobry, prosty wzorzec:

  • osobny użytkownik na serwerze, np. deploy,
  • Kluczowe Wnioski

  • Pipeline CI/CD dla Dockera można zbudować tanio i skutecznie, opierając się głównie na GitHub Actions, zewnętrznym rejestrze obrazów i prostym serwerze VPS z Dockerem zamiast drogich, rozbudowanych platform.
  • Sensowny przepływ od commitu do produkcji obejmuje automatyczne testy, budowanie obrazu, tagowanie i wysłanie go do rejestru, a na końcu zdalny deploy kontenera – bez ręcznego logowania się na serwer i klepania komend z konsoli.
  • Przy ograniczonym budżecie kluczowe są: poprawne zarządzanie sekretami, minimalizacja kroków ręcznych (człowiek tylko podejmuje decyzję o wdrożeniu), trzymanie się darmowych limitów GitHub Actions i prostych hostingów kontenerów.
  • Repozytorium musi być przygotowane pod automatyzację: przejrzysta struktura katalogów (src, tests), Dockerfile w znanym miejscu, katalog .github/workflows z definicją pipeline’u oraz .env.example zamiast trzymania konfiguracji i haseł w kodzie.
  • Produkcyjny obraz Dockera powinien być lekki i powtarzalny: bazować na slim/alpine, używać wieloetapowego builda, odcinać dev-dependencies i mieć jasno zdefiniowany CMD/ENTRYPOINT, żeby kontener po starcie „po prostu działał”.
  • Konfiguracja środowiskowa musi być oddzielona od kodu: aplikacja czyta ustawienia ze zmiennych środowiskowych, lokalne .env nie trafiają do gita, a sekrety trzymane są w GitHub Secrets i wstrzykiwane do kontenerów dopiero na etapie deployu.