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:
- Testy aplikacji – uruchamiane przy każdym commicie lub pull requeście; jeśli testy padają, dalej nic się nie dzieje.
- Budowanie obrazu Dockera – z kodu, który przeszedł testy; najlepiej z deterministycznym Dockerfile.
- Tagowanie i push do rejestru – obraz trafia do Docker Hub lub GHCR z jednoznacznym tagiem (wersja, SHA).
- 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
.envignorowane przez Git (.gitignore), - w repozytorium tylko
.env.examplez nazwami zmiennych bez wrażliwych wartości, - na serwerze staging/produkcja własne
.envlub 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
maini 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 (
needsw 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.

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
pushdo określonych gałęzi i napull_requestdomain, - 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 cizamiastnpm install), - czytelne rozdzielenie testów szybkich i wolnych (np.
npm testvsnpm 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:
- W ustawieniach repozytorium wejść w Branches → Branch protection rules.
- Dodać regułę dla
main. - 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 –
latestlubprod, 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.iolubindex.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
maini/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:
developlubstaging– 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:
- W Settings → Environments utwórz np.
stagingiproduction. - W każdym ustaw osobne sekrety, np.
SSH_HOST,SSH_USER,ENV_FILE. - Dla
productionwłą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ą.

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:
- Mały VPS (np. 1–2 GB RAM) z zainstalowanym Dockerem.
- Na serwerze logowanie do rejestru (Docker Hub / GHCR) z użyciem tokena technicznego.
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.
- Na serwerze wygeneruj użytkownika technicznego, np.
deploy, dodaj go do grupydocker. - 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). - Upewnij się, że serwer ma już zalogowane
docker logindo 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 nomożna później zastąpić prekonfiguracjąknown_hosts, żeby wyeliminować potencjalne MITM.--env-filewskazuje 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
mainistaging, - 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 -xw 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.






