Scenka wyjściowa: kiedy każdy push do zdalnego CI boli
Wyobraź sobie wieczór, w repozytorium jedna drobna zmiana w pliku YAML od pipeline’u, teoretycznie kosmetyka. Push, czekanie, kolejka na zdalnym CI, kilka minut na “pending”, potem fail przez literówkę w zmiennej. Poprawka, kolejny push, kolejne kilka minut. Po kilku takich iteracjach znikają chęci do eksperymentowania z lepszym pipeline’em, zostaje frustracja i byle działało.
Kiedy każde przetestowanie pipeline’u developerskiego wymaga pusha na GitHub, GitLaba czy Azure DevOps, powstaje dziwny “podatek od refaktoryzacji” – teoretycznie system CI/CD ma przyspieszać pracę, a w praktyce zniechęca do zmian w samym pipeline’ie. Zaczyna się strzelanie na ślepo: dopisywanie kolejnych ifów w YAML, kopiowanie konfiguracji od innych repo, kopiowanie całych jobów zamiast ich porządkowania. Bo każda iteracja kosztuje czas i rozbija fokus.
Brak lokalnego środowiska CI sprowadza się do prostego wzorca: pipeline’y rozwijają się wolniej niż aplikacja. Developerzy boją się ich dotykać, DevOpsi są zasypywani prośbami o “poprawienie pipeline’u”, a proste błędy typu zła nazwa zmiennej czy brakujący pakiet wychodzą na jaw dopiero na zdalnym serwerze CI. Gdyby dało się odpalić 90% pipeline’u na własnym laptopie w ciągu kilkunastu sekund, sytuacja wyglądałaby inaczej.
Zbudowanie sensownego, lokalnego środowiska CI to nie fanaberia ani “zabawka DevOpsa”. To mechanizm skracający pętlę feedbacku w najtrudniejszym miejscu – w konfiguracji pipeline’ów developerskich. Kto raz poczuje różnicę między “poprawka → push → 10 minut czekania” a “poprawka → act / gitlab-runner exec → wynik po kilkudziesięciu sekundach”, ten przestaje traktować lokalne CI jako opcję, a zaczyna jako standard.
Jeżeli celem jest szybkie, bezpieczne testowanie pipeline’ów przed wypchnięciem zmian do centralnego systemu, potrzebne jest konkretne, powtarzalne podejście: odpowiednie narzędzia, prosta architektura, kilka dobrych praktyk i minimalna dyscyplina w strukturze repozytorium.
Co to znaczy „lokalne CI” w praktyce – doprecyzowanie oczekiwań
Lekka symulacja zamiast prywatnego serwera CI
“Lokalne środowisko CI” wielu osobom kojarzy się z postawieniem pełnego, on-premise serwera Jenkins czy GitLab CE na własnym serwerze. To nie o to chodzi. Tutaj celem jest lekka symulacja pipeline’ów na laptopie, bez całego ciężkiego zaplecza: użytkowników, uprawnień, kolejki buildów, integracji z SCM.
Lokalne CI w tym znaczeniu to zestaw narzędzi i skryptów, które biorą konfigurację pipeline’u (np. .github/workflows/ci.yml albo .gitlab-ci.yml) i odpalają ją w możliwie zbliżonym środowisku do tego, co uruchamia się na serwerze. Bez webhooków, bez pull requestów, bez UI – po prostu: “uruchom ten job local i pokaż logi”.
Taka symulacja pipeline’u na laptopie nie ma ambicji zastąpić centralnego CI. Ma jedną, bardzo konkretną funkcję: pozwolić złapać błędy konfiguracji i podstawowe problemy środowiskowe zanim kod dotrze do zdalnego runnera. Resztą niech zajmie się produkcyjny system CI/CD.
Jakie elementy CI warto odwzorować lokalnie
Żeby lokalne środowisko CI faktycznie pomagało, a nie było kolejną warstwą teorii, trzeba zdecydować, co dokładnie ma być symulowane na laptopie. W praktyce liczy się kilka aspektów:
- Build i testy – kompilacja, uruchamianie testów jednostkowych, ewentualnie integracyjnych, lintowanie kodu; wszystko, co odpala się przy każdym pushu.
- Lint i statyczna analiza – ESLint, flake8, Sonar scanner w trybach, które nie wymagają pełnej komunikacji z zewnętrznym serwerem.
- Podstawowe secrety i zmienne środowiskowe – API keye do sandboxów, testowe hasła do baz, tokeny do prywatnych rejestrów; w wersji “udomowionej”, nie produkcyjnej.
- Artefakty lokalne – paczki buildów, pliki raportów, wygenerowana dokumentacja; nie trzeba ich wysyłać do centralnego storage, ale dobrze, żeby powstawały tak samo jak na serwerze.
- Struktura jobów – mniej chodzi o perfekcyjne odzwierciedlenie wszystkiego, a bardziej o to, żeby “lokalny” job odpowiadał konkretnemu jobowi z centralnego CI i używał podobnych komend.
Jeżeli te elementy działają lokalnie, ryzyko niespodzianek na zdalnym CI mocno spada. Dodatkowo developerzy zyskują możliwość iterowania na logice pipeline’u, zamiast zgadywać, jak zadziała na serwerze.
Czego nie trzeba na siłę odtwarzać na laptopie
Są elementy infrastruktury CI/CD, których odtwarzanie lokalnie ma marginalny sens albo jest nieproporcjonalnie kosztowne:
- Pełne środowiska staging/production – kilka klastrów Kubernetes, sieci VPC, load balancery, WAF-y; tego nie ma sensu przenosić na laptopa. Wystarczy symulacja pojedynczych usług (np. lokalny Postgres, lokalny Redis).
- Ciężkie integracje zewnętrzne – np. z systemami płatności czy ERP; w lokalnym CI lepiej używać stubów, sandboxów lub mocków.
- Cała logika release’owa – deploy do wielu środowisk, canary release, blue/green; to zazwyczaj sprawa centralnego CI i środowisk testowych.
- Zaawansowane polityki bezpieczeństwa – skanowanie obrazów pod kątem CVE, SAST z pełnym profilem; w lokalnym CI można zyskać większość korzyści prostszymi narzędziami (np. trivy, prosty skaner dependency).
Lepiej zainwestować czas w solidne odwzorowanie podstawowych jobów build/test niż w idealną replikę produkcyjnego ekosystemu. Lokalny CI ma ułatwiać pracę, nie być kolejnym systemem do utrzymywania.
Minimalny “Definition of Done” dla lokalnego CI
Dla zespołu, który dopiero wdraża lokalne środowisko CI, dobrze jest doprecyzować, co to znaczy, że “lokalny CI działa”. Minimalna definicja powinna zawierać:
- Możliwość uruchomienia co najmniej jednego głównego pipeline’u lokalnie (np. pipeline’a z gałęzi main/develop).
- Możliwość odpalenia pojedynczego joba (np. “tests”, “lint”) z poziomu jednej komendy w terminalu.
- Wsparcie dla podstawowych sekretów – lokalny plik z secretami zamapowany do jobów tak, jak robi to zdalny CI.
- Stabilne logi – wyjście z jobów jest czytelne, można łatwo znaleźć przyczynę błędu.
- Dokumentację w repozytorium – prosty plik
LOCAL_CI.mdalbo sekcja w README z instrukcją uruchamiania.
Jeśli te punkty są spełnione, lokalne środowisko CI zaczyna spełniać swoją rolę: developer przed pushem ma realną możliwość zweryfikowania większości problemów pipeline’u bez dotykania zdalnego systemu.

Wymagania wstępne i podejście architektoniczne do lokalnego CI
Sprzęt: co musi wytrzymać laptop
Lokalne środowisko CI obciąża laptop podobnie jak pełny build na zdalnym runnerze. W praktyce oznacza to, że przy projektowaniu trzeba uwzględnić:
- RAM – sensownym minimum jest 16 GB, szczególnie przy cięższych projektach (Java, Node z wieloma modułami, mikroserwisy). Da się z mniejszą ilością, ale wtedy trzeba bardziej pilnować równoczesności jobów.
- CPU – 4 rdzenie fizyczne to przyzwoity punkt startu. Kontenery, testy, kompilacje – to wszystko lubi mieć kilka wątków do dyspozycji.
- Dysk – obrazy Dockera, cache zależności, artefakty; potrafią zająć dziesiątki gigabajtów. SSD przyspiesza ogromnie. Dobrą praktyką jest regularne czyszczenie:
docker system prune.
Jeżeli pipeline’y są wyjątkowo ciężkie, rozwiązaniem bywa przerzucenie części pracy na stacje robocze/dev-boksy (np. mocniejszy serwer w biurze, ale z dostępem SSH) i traktowanie laptopa jako thin clienta. Sam koncept lokalnego CI zostaje, zmienia się jedynie fizyczna maszyna wykonująca joby.
Konteneryzacja jako fundament lokalnego CI
Większość nowoczesnych systemów CI (GitHub Actions, GitLab CI, Bitbucket Pipelines) i tak uruchamia joby w kontenerach. Dlatego kluczową rolę w lokalnym CI pełni Docker (ewentualnie alternatywy typu Podman). To on pozwala odwzorować:
- konkretny obraz bazowy używany przez runnera,
- wersje języków i narzędzi (Node, Python, JDK, Maven, Gradle, CLI do chmury),
- sieć między usługami – baza danych, kolejka, cache, dodatkowe serwisy.
Bez konteneryzacji można oczywiście pisać skrypty, które odpalają się na lokalnym systemie operacyjnym, ale wtedy bardzo trudno zagwarantować spójność między środowiskiem developera a środowiskiem runnera CI. Docker eliminuje całą klasę błędów typu “u mnie działa, na CI nie działa”.
Docker Compose przydaje się jako sposób na zestawianie kilku usług na raz – np. aplikacja + Postgres + Redis. Lokalny pipeline może wtedy wystartować Compose przed testami, a po ich zakończeniu wyczyścić kontenery. To szczególnie ważne przy testach integracyjnych, które w chmurze często polegają na usługach typu “services” w GitLab CI czy “service containers” w GitHub Actions.
Czy da się zbudować lokalne CI bez Dockera
Technicznie można, praktycznie oznacza to dużo kompromisów. Zamiast Dockera można używać:
- lokalnych instalacji języków + pyenv, nvm, asdf,
- wirtualnych środowisk (virtualenv, venv, conda),
- pakietów systemowych (apt, brew).
Taki setup pozwoli odtwarzać niektóre pipeline’y (szczególnie proste: lint + testy jednostkowe), ale szybko pojawią się problemy:
- trudno symulować dokładne obrazy runnerów z CI,
- trudno odtworzyć sieć i usługi towarzyszące (bazy, kolejki, inne mikroserwisy),
- konfiguracja jest mocno zależna od systemu operacyjnego developera.
Dla pojedynczego projektu lokalne CI bez Dockera może mieć sens jako tymczasowe rozwiązanie, ale w skali zespołu / organizacji jest to ślepa uliczka. Architektura oparta o kontenery jest bardziej przenośna i przewidywalna.
Warstwy architektury lokalnego CI
Przydatne jest myślenie o lokalnym CI warstwowo. Najprościej podzielić to na trzy warstwy:
- Warstwa narzędzi – Docker, Docker Compose, ewentualnie kind/Minikube (jeśli pipeline’y operują na Kubernetesie); do tego narzędzie specyficzne dla danego CI (np. act dla GitHub Actions, gitlab-runner dla GitLaba).
- Warstwa orkiestracji pipeline’ów – skrypty, Makefile, wrappery, które mapują “
make ci-test” albo “./ci-local.sh test” na konkretną komendę act / gitlab-runner exec / Dockera. - Warstwa konfiguracji i sekretów – pliki YAML pipeline’ów, pliki z secretami, zmienne środowiskowe, profile lokalne (np. tryb “quick” vs “full”).
Im mniej rozjazdu między tymi warstwami w środowisku lokalnym a zdalnym, tym mniej niespodzianek po puszczeniu kodu do centralnego CI. Dobrym kryterium jest to, czy komendy używane w jobach na lokalnym CI są 1:1 takie same jak na serwerze, czy też trzeba utrzymywać dwie oddzielne ścieżki.
Przegląd narzędzi do lokalnego uruchamiania pipeline’ów
GitHub Actions lokalnie: narzędzie act
Dla GitHub Actions standardem de facto stało się narzędzie act. Pozwala ono uruchomić workflow z katalogu .github/workflows na lokalnej maszynie, wykorzystując Dockera jako runtime. Działa to w uproszczeniu tak, że act czyta plik YAML workflowu, symuluje event (push, pull_request itp.), a następnie odpala poszczególne joby jako kontenery oparte o zdefiniowane obrazy.
Najważniejsze cechy act w kontekście szybkiego testowania pipeline’ów developerskich:
- obsługa większości standardowych eventów (push, pull_request, workflow_dispatch),
- mapowanie GitHub Actions na lokalne obrazy – można ustawić, jakie obrazy odpowiadają standardowym
runs-on: ubuntu-latestitd., - wspieranie sekretów i zmiennych przez plik
.secretsi flagi CLI, - czytelne logi podobne do tych z GitHub Actions.
Symulowanie eventów i ograniczeń GitHub Actions w act
Kiedy pierwszy raz odpalisz workflow w act, szybko okaże się, że to nie jest magiczny “GitHub Actions w puszce”. Jeden z typowych scenariuszy: lokalnie wszystko przechodzi na “push”, a na zdalnym CI ten sam workflow w kontekście “pull_request” sypie błędami uprawnień albo brakujących danych o branchu.
Kluczem do sensownego użycia act jest świadome symulowanie eventów i ograniczeń:
- Wybór eventu – domyślnie act może użyć pierwszego eventu zdefiniowanego w workflow, co często nie pokrywa się z użyciem w organizacji. Dobrą praktyką jest jawne wskazanie eventu, np.
act pull_requestczyact workflow_dispatch. - Payload eventu – w niektórych przypadkach trzeba dostarczyć własny JSON z eventem (flaga
-e). Przydaje się, gdy workflow korzysta z zaawansowanych pól eventu (np. labeli PR, review state). - Ograniczenia uprawnień – tokeny w GitHub Actions mają często mniejsze uprawnienia niż token lokalny. Symulacja tego stanu wymaga ustawienia zmiennych środowiskowych tak, aby zachowywać się “jak na CI”, a nie jak pełny admin.
Im bardziej workflow polega na danych z eventów (gałąź źródłowa/target, etykiety, review), tym większy sens ma trzymanie kilku przykładowych payloadów eventów w repo, np. w katalogu .github/events/ i odpalanie act -e .github/events/pr-labeled.json. Deweloper nie musi wtedy ręcznie budować JSON-a przy każdym teście.
Mapowanie obrazów runnerów i akcji w act
Drugi typ problemów pojawia się, kiedy workflow polega na tym, że “na runnerze jest Ubuntu z X i Y”. Na GitHubie to zapewniają obrazy runs-on: ubuntu-latest / windows-latest z preinstalowanym zestawem narzędzi. Lokalnie takiego luksusu nie ma – trzeba dobrać obrazy samodzielnie.
act pozwala zdefiniować mapowanie “runner → obraz Dockera” w pliku .actrc lub przez parametry CLI. Przykład minimalnej konfiguracji:
# .actrc
-P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest
-P ubuntu-22.04=ghcr.io/catthehacker/ubuntu:act-22.04Obrazy catthehacker/ubuntu:act-* są specjalnie przygotowane pod act i imitują standardowe środowisko GitHub Actions. To dobry punkt startowy, ale w praktyce często trzeba je rozszerzyć:
- zainstalować dodatkowe SDK (np. specyficzną wersję JDK, .NET, Android SDK),
- dodać narzędzia CLI chmury, z którymi integruje się pipeline,
- podmienić wersje systemowych pakietów (np.
libssl) na zgodne z produkcyjnym runnerem.
Rozszerzony obraz można utrzymywać jako osobny Dockerfile w repo, budować go lokalnie i konfigurować w .actrc. Dzięki temu developerzy mają spójny runtime – taki sam na każdym laptopie i znacznie bliższy temu z GitHuba. Każda różnica, jaką uda się tu wyeliminować, to mniej “magicznych” błędów typu “na GitHubie brakuje narzędzia X”.
Praca z sekretami i zmiennymi środowiskowymi w act
Moment, w którym pipeline zaczyna dotykać prawdziwych zasobów (bazy, S3, API), wymusza uporządkowanie sekretów. Na zdalnym CI są trzymane w UI platformy, lokalnie trzeba znaleźć wygodny i bezpieczny model.
act wspiera kilka źródeł sekretów:
- Plik
.secrets– prosty formatKEY=VALUE, ładowany przez--secret-file. To praktyczny wybór dla większości przypadków. - Zmienne środowiskowe – eksportowane przed komendą
act, szczególnie przydatne w integracji z menedżerami sekretów (pass, 1Password CLI, AWS SSM). - Interaktywne podawanie – wygodne w eksperymentach, ale męczące w codziennej pracy i trudne do zautomatyzowania.
W większości zespołów sprawdza się konwencja typu:
# .gitignore
.secrets.local
# lokalny plik z sekretami
GITHUB_TOKEN=ghp_...
DATABASE_URL=postgres://...oraz prosty alias:
alias gha="act --secret-file .secrets.local"Taka konfiguracja spełnia kilka warunków naraz: sekrety nie lądują w repo, każdy developer ma swój zestaw, a workflow z ${{ secrets.XYZ }} zachowuje się w lokalnym CI identycznie jak w chmurze. Dobrze jest też ustalić minimalny zestaw “wymaganych” sekretów oraz dodać prosty check w Makefile lub wrapperze, który przed uruchomieniem act sprawdza ich obecność.
Optymalizacja czasu wykonania workflowów w act
Niektóre workflowy na GitHub Actions potrafią trwać kilkanaście minut. Odpalone 1:1 lokalnie zabiją laptopa i zniechęcą do używania lokalnego CI. Zamiast tego lepiej wyróżnić dwa tryby: “szybki feedback dla developera” i “pełny pipeline jak na CI”.
Można to osiągnąć kilkoma podejściami:
- Warunki na gałąź i flagi – dodanie dodatkowych warunków
if:opartych o zmienne środowiskowe, np.env.CI_FAST. Przy lokalnym uruchomieniu ustawiaszCI_FAST=truei pomijasz cięższe joby (np. pełne testy e2e, skany bezpieczeństwa). - Wyodrębnienie krytycznych jobów – zamiast odpalać cały workflow, w lokalnym CI koncentrujesz się na 1–2 kluczowych jobach: build + test. Reszta (deploy, release, integracje) zostaje wyłącznie na zdalnym CI.
- Cache Dockera i zależności – lokalny builder może być nawet szybszy niż zdalny runner, o ile obrazy bazowe i dependency cache nie są czyszczone przy każdym uruchomieniu.
Dobrym kompromisem jest wprowadzenie w repo dwóch komend:
make gha-fast– używa act z profilem/flagą omijającą ciężkie joby,make gha-full– odpala workflow maksymalnie zbliżony do produkcyjnego, kosztem dłuższego czasu.
Deweloper większość dnia pracuje na trybie “fast”, a “full” uruchamia w sytuacjach granicznych – przed większym refactorem lub gdy podejrzewa, że mógł naruszyć mniej oczywiste części pipeline’u.
Typowe pułapki przy korzystaniu z act
Pierwsze tygodnie z lokalnym CI dla GitHub Actions zwykle obnażają kilka schematycznych problemów. Zwykle powtarzają się te same motywy:
- Akcje nieobsługiwane lub działające inaczej – część marketplace actions może korzystać z funkcji GitHuba, których act jeszcze nie emuluje. Rozwiązaniem jest czasem zastąpienie ich prostszym skryptem lub akcją, która działa “bardziej po ludzku” (np. zamiast złożonego action do formatowania PR – zwykły skrypt bash + curl).
- Różnice w środowisku sieciowym – na lokalnym CI dostęp do usług w intranecie czy VPN może być inny niż z runnerów GitHub Actions. Dobrze jest dodawać do workflowów mechanizmy degradacji: jeśli endpoint X jest nieosiągalny, pipeline przechodzi w tryb stubów, zamiast się wykrzaczać.
- Problemy z uprawnieniami do systemu plików – kontenery odpalane przez act tworzą pliki z użytkownikiem root, co bywa uciążliwe na hostowym systemie plików. Pomaga jawne ustawienie
--usernslub budowanie obrazów tak, by używały nieuprzywilejowanego użytkownika.
Większość z tych pułapek ujawnia się szybko i po pierwszej iteracji konfiguracji rzadko wraca. Kluczowe jest, by nie maskować problemów “hackami lokalnymi”, tylko modyfikować workflow tak, by był stabilniejszy także na zdalnym CI.

Projektowanie lokalnego środowiska CI krok po kroku
Identyfikacja krytycznych pipeline’ów i jobów
W jednym z zespołów, które wdrażały lokalne CI, sytuacja wyglądała tak: kilkanaście workflowów, dziesiątki jobów, pięć różnych języków w monorepo. Gdyby próbowali odwzorować wszystko naraz, projekt utknąłby na etapie “ustawek narzędziowych”. Zaczęli więc od prostszego pytania: co naprawdę boli w codziennej pracy?
Dobry start to krótka analiza:
- które pipeline’y uruchamiają się najczęściej (np. każdy PR do
develop), - które joby najczęściej się wywalają (lint, unit testy, integ testy),
- które kroki pipeline’u są najbardziej kosztowne czasowo.
Efektem może być tabela w stylu:
- Pipeline “PR checks”: joby
lint,unit-tests,build– priorytet wysoki. - Pipeline “Nightly e2e”: ciężki, długo się wykonuje – priorytet niski dla lokalnego CI.
- Pipeline “Release”: integracja z App Store / Play Store – tylko zdalne CI.
Na tej podstawie projekt lokalnego CI ma jasno określony scope. Deweloperzy wiedzą, że lokalnie odpalą to, co odpowiada “PR checks”, a nie całą orkiestrację nocnych testów czy wydawania wersji.
Uzgodnienie interfejsu: jedna komenda na start
Największy wróg lokalnego CI to zbyt skomplikowany sposób użycia. Jeśli developer musi pamiętać cztery różne komendy z długimi flagami, prędzej odpali “git push” i poczeka na zdalny pipeline. Dlatego sensownie jest zacząć od ustalenia prostego interfejsu.
Najczęściej wybierane formy:
- Makefile – komendy typu
make ci,make ci-test,make ci-lint. Działa dobrze w projektach wieloplatformowych. - Skrypt bash/powershell –
./ci-local.sh test,./ci-local.sh pr. Łatwy do rozbudowy o dodatkowe logowanie, walidację sekretów itd.
Ważne, by już na tym etapie zaplanować abstrakcję: górny poziom (Make/skrpt) nie powinien być zależny od konkretnego narzędzia CI. W środku można wywołać act, gitlab-runner exec lub cokolwiek innego, ale z zewnątrz developer używa jednej, spójnej komendy.
Projekt warstwy usług towarzyszących
Jeśli pipeline’y korzystają z usług typu baza danych, kolejka, broker eventów, sensowniej jest potraktować je jako osobną warstwę infrastruktury lokalnego CI niż “przypadkowe kontenery odpalane ad hoc”. Dobrą praktyką jest:
- utrzymywanie jednego pliku
docker-compose.ci.ymlz definicją usług wymaganych przez pipeline’y, - dodanie krótkich targetów w Makefile:
make ci-up/make ci-down, - konfiguracja portów i danych logowania tak, aby pokrywały się z tym, co pipeline’y zakładają na zdalnym CI (np. ten sam user/hasło do Postgresa).
Przykładowy minimalny docker-compose.ci.yml dla aplikacji web + Postgres + Redis:
version: "3.9"
services:
db:
image: postgres:15
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- "5432:5432"
cache:
image: redis:7
ports:
- "6379:6379"W pipeline’ach (zarówno lokalnych, jak i zdalnych) konfiguracja aplikacji wskazuje na te same hosty/porty: DB_HOST=db, REDIS_HOST=cache. Takie podejście zmniejsza liczbę specyficznych “hacków” w YAML-ach pipeline’u.
Standaryzacja layoutu w repozytorium
Im bardziej zespół rośnie, tym bardziej zaczyna boleć brak porządku: skrypty CI rozsiane po katalogach, różne nazwy, brak konwencji. Lokalny CI to dobra okazja, by wprowadzić trochę dyscypliny. Jeden z prostszych układów:
.github/workflows/– workflowy GitHub Actions,ci/local/– skrypty specyficzne dla lokalnego CI (wrappery, przykładowe event payloady, plik.actrc),ci/docker/– Dockerfile dla obrazów używanych w jobach,docker-compose.ci.yml– usługi towarzyszące.
Nad tym można umieścić cienką warstwę dokumentacji: plik LOCAL_CI.md z krótkim opisem: jak zacząć, jak włączyć usługi pomocnicze, jak skonfigurować sekrety. Jeżeli w organizacji są setki repozytoriów, uzgodniony layout i zestaw komend znacząco obniża próg wejścia dla nowych osób.
Iteracyjne odwzorowywanie kolejnych jobów
Mapowanie jobów zdalnych na lokalne odpowiedniki
Wielu zespołom lokalne CI kojarzy się z “albo wszystko, albo nic”. Jeden z teamów backendowych próbował kiedyś odwzorować całą macierz buildów (trzy wersje Pythona, kilka baz danych, różne systemy operacyjne) 1:1 na laptopach. Skończyło się tym, że tylko jedna osoba to odpalała, a reszta udawała, że lokalnego CI nie ma.
Dużo skuteczniejsze jest rozpisanie jobów na klasy funkcjonalne i dopiero wtedy decydowanie, które mają lokalne odpowiedniki. Przykładowy podział:
- Joby walidacyjne – lint, format, podstawowe testy jednostkowe; pierwsza kandydatura do lokalnego CI.
- Joby integracyjne – testy z bazą, message brokerem, external API w trybie stubów; często częściowo odwzorowywane lokalnie.
- Joby środowiskowe – build obrazów, publikacja artefaktów; zwykle zostają na zdalnym CI, lokalnie testuje się tylko “suchy” build.
- Joby operacyjne – deploy, migracje, smoke testy po wdrożeniu; trzymane z dala od lokalnego środowiska.
Na tej bazie łatwo zdefiniować, które joby mają swój odpowiednik lokalny i jak będą nazywane. Dobrą praktyką jest, żeby nazwy nie były zupełnie różne – jeśli na CI jest job unit-tests, lokalny wrapper może nazywać się ci-unit, ale i tak w logach powinno być widać: “odpalam job: unit-tests”.
Stopniowe uruchamianie kolejnych fragmentów pipeline’u
W jednym z monorepo pierwsze podejście brzmiało: “Dzisiaj wdrażamy lokalne CI, więc musi działać wszystko”. Po trzech dniach walki z edge case’ami i specyficznymi akcjami marketplace wszyscy mieli serdecznie dość. Dopiero przejście na iteracyjne podejście odblokowało prace.
Praktyczny plan na kilka tygodni może wyglądać tak:
- Tydzień 1: tylko najprostszy pipeline “PR checks” – lint + unit. Celem jest, by każdy developer umiał odpalić
make cii zobaczyć ten sam zestaw błędów co na zdalnym CI. - Tydzień 2: dokładanie jednego joba integracyjnego (np. testy z Postgres + Redis), doprowadzenie do tego, by działał lokalny
docker-compose.ci.yml. - Tydzień 3: eksperymenty z cięższymi elementami (np. budowa obrazów), ale w trybie “fast” – bez publikacji, bez pushowania do rejestru.
Każda iteracja kończy się drobnymi poprawkami w dokumentacji i w wrapperach. Dzięki temu lokalne CI nie jest jednorazowym “projektem wdrożeniowym”, tylko ewoluuje razem z pipeline’ami.
Obsługa sekretów i danych konfiguracyjnych
Najczęstszy zgrzyt przy lokalnym CI to sekrety. Na zdalnym runnerze wszystko wisi w bezpiecznym store, a lokalnie developer kończy z katalogiem pełnym .env.local.ci i plikami “do ręcznego uzupełnienia”. W jednym z projektów skończyło się to tym, że ktoś przypadkiem wrzucił do repo lokalny JSON z kluczami do środowiska staging.
Bezpieczniejszy i wygodniejszy wzorzec:
- W repo wchodzi szablon pliku, np.
ci/local/.env.example, z pustymi wartościami lub dummy danymi. - Developer kopiuje go do
ci/local/.env, który jest wyłączony z wersjonowania (.gitignore). - Wrapper
make cilub./ci-local.shdba o wczytanie tego pliku i przekazanie zmiennych środowiskowych do act / gitlab-runner.
Dla bardziej wrażliwych środowisk przydaje się integracja z managerem sekretów (Vault, 1Password CLI, SOPS). Zamiast wprowadzać sekrety ręcznie, developer uruchamia najpierw komendę w stylu make ci-secrets-sync, która:
- pobiera minimalny zestaw sekretnych danych wymagany do lokalnych testów,
- zapisuje je do zaszyfrowanego pliku lokalnego lub tylko do zmiennych środowiskowych bieżącej sesji.
Takie podejście pomaga uniknąć sytuacji, w której deweloperzy “na skróty” używają prawdziwych production tokenów w lokalnych testach, bo “tylko tak działa”.
Zapewnienie deterministyczności i powtarzalności
Gdy lokalne CI zaczyna być używane codziennie, wychodzi inny problem: na jednym laptopie wszystko przechodzi, na drugim – losowe błędy. W jednym z zespołów frontowych okazało się, że część osób miała Node’a zainstalowanego z systemowego repozytorium, a część przez nvm; do tego różne wersje Yarn. Wynik był prosty: lokalne CI generowało inne lockfile niż zdalne.
Trzy elementy znacząco zmniejszają ten chaos:
- Wersjonowanie narzędzi – pliki w stylu
.tool-versions(asdf),.nvmrc,.ruby-versionoraz jasna informacja wLOCAL_CI.md, z jakich wersji korzysta pipeline. - Konteneryzacja buildów – zamiast polegać na macOS/Windows, lokalne CI odpala joby w dedykowanych obrazach Docker, tak jak zdalny runner. Różnice systemowe ograniczają się wtedy do jądra i sterowników.
- Lockfile i cache – wymuszenie obecności
package-lock.json,poetry.lock,Gemfile.lockitd. oraz ich używanie zarówno lokalnie, jak i na zdalnym CI.
W połączeniu z jasno opisanym sposobem instalacji narzędzi (“asdf install” zamiast “zainstaluj sobie Node’a”) zmniejsza to kilometrowe dyskusje w stylu “u mnie działa”.
Monitoring i feedback z użycia lokalnego CI
W jednym z działów backendowych po dwóch miesiącach ktoś rzucił na retro: “wydaje mi się, że tego lokalnego CI prawie nikt nie używa”. Nikt nie potrafił tego ani potwierdzić, ani obalić, bo wszystko odbywało się lokalnie. Dopiero proste logowanie wywołań zaczęło pokazywać realny obraz.
Do wrappera ci-local.sh można dodać lekką telemetrię, np.:
- logowanie do pliku w repo lub w
~/.local– ile razy dziennie odpalany jest lokalny pipeline, który wariant (fast/full), ile trwał, czy skończył się sukcesem, - opcjonalne (za zgodą zespołu) wysyłanie zanonimizowanych statystyk do prostego endpointu – bez commit hashy i loginów, tylko typ użytego workflow i status.
Po kilku tygodniach takie dane jasno pokazują, czy narzędzie realnie pomaga. Jeśli widać, że 90% odpaleń kończy się błędami konfiguracji, to problem leży nie w “leniwym zespole”, ale w zbyt skomplikowanym setupie.
Konfiguracja lokalnego CI dla GitLab CI z użyciem gitlab-runner
Scenariusz: debugowanie .gitlab-ci.yml bez spamowania zdalnego CI
Zespoły na GitLabie często mają podobny ból jak przy GitHub Actions: każda zmiana w .gitlab-ci.yml to commit, push i nadzieja, że tym razem YAML się nie wywali. Jeden z zespołów devopsowych opowiadał, że przy migracji pipeline’ów do nowego runnera potrafili robić po kilkadziesiąt “dummy commitów” dziennie tylko po to, żeby zobaczyć, gdzie się wysypało.
Lokalne uruchamianie jobów przez gitlab-runner exec rozwiązuje znaczną część tego problemu. Pozwala szybko iterować nad definicją jobów, obrazami, zmiennymi i artefaktami, bez czekania w kolejce na zdalny runner i bez zaśmiecania historii git.
Instalacja i podstawowa konfiguracja gitlab-runner lokalnie
Pierwszy krok to uruchomienie własnego runnera w trybie lokalnym. Można to zrobić na dwa sposoby: instalując binarkę bezpośrednio na systemie lub opakowując wszystko w Dockera.
Przy prostych zastosowaniach wystarczy bezpośrednia instalacja:
# macOS (Homebrew)
brew install gitlab-runner
# Linux (przykład Debian/Ubuntu)
curl -L --output gitlab-runner
https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64
chmod +x gitlab-runner
sudo mv gitlab-runner /usr/local/bin/W tym scenariuszu gitlab-runner będzie używany w trybie “standalone”, bez rejestracji w konkretnym projekcie GitLab. Cała magia dzieje się lokalnie poprzez komendę exec, która czyta definicje z .gitlab-ci.yml.
Uruchamianie pojedynczych jobów z .gitlab-ci.yml
Naturalnym odpowiednikiem “szybkiego feedbacku dla developera” jest możliwość uruchomienia konkretnego joba z pliku .gitlab-ci.yml. W praktyce sprowadza się to do jednej komendy:
gitlab-runner exec docker test_unitGdzie test_unit to nazwa joba z pliku YAML. Przykładowy fragment konfiguracji może wyglądać tak:
stages:
- lint
- test
- build
variables:
GIT_STRATEGY: clone
lint:
stage: lint
image: node:20
script:
- npm ci
- npm run lint
test_unit:
stage: test
image: node:20
script:
- npm ci
- npm testPodczas lokalnego uruchomienia gitlab-runner exec docker test_unit wykona dokładnie ten sam zestaw kroków, co zdalny runner wykorzystujący executor docker. Warto zwrócić uwagę na kilka detali:
exec dockeroznacza użycie lokalnego Dockera do tworzenia kontenera z obrazem zdefiniowanym w jobie.- Domyślnie runner zamontuje aktualny katalog jako volume w kontenerze, więc zmiany w kodzie są widoczne bez dodatkowych kroków.
- Zmienne z sekcji
variables:zostaną wstrzyknięte automatycznie, ale sekrety z GitLaba trzeba zapewnić osobno (np. przez--env).
Symulowanie zmiennych środowiskowych GitLaba
Spora część pipeline’ów GitLabowych opiera się na zmiennych takich jak CI_COMMIT_BRANCH, CI_COMMIT_TAG, CI_PIPELINE_SOURCE czy customowych tokenach. Lokalnie te zmienne nie istnieją, więc trzeba je zasymulować.
Najprostsza droga to użycie flagi --env:
gitlab-runner exec docker test_unit
--env CI_COMMIT_BRANCH=feature/local-ci
--env CI_PIPELINE_SOURCE=pushPrzy częstym użyciu wygodniejsze jest przygotowanie pliku z definicją zmiennych, np. ci/local/env.gitlab.local:
# ci/local/env.gitlab.local
CI_COMMIT_BRANCH=local-debug
CI_PIPELINE_SOURCE=web
MY_API_BASE_URL=http://host.docker.internal:8080Wrapper w stylu ./ci-local.sh może wtedy ładować ten plik przed wywołaniem runnera:
#!/usr/bin/env bash
set -euo pipefail
JOB=${1:-test_unit}
# załaduj zmienne z pliku, jeśli istnieje
if [ -f "ci/local/env.gitlab.local" ]; then
export $(grep -v '^#' ci/local/env.gitlab.local | xargs)
fi
gitlab-runner exec docker "$JOB"Dzięki temu developer może łatwo przełączać różne scenariusze uruchomienia, podmieniając pojedynczy plik z env lub trzymając kilka wariantów (np. env.gitlab.pr, env.gitlab.tag).
Integracja lokalnego gitlab-runner z docker-compose.ci.yml
Gdy pipeline’y zaczynają korzystać z baz danych lub kolejek, naturalnym pytaniem jest, jak połączyć joby odpalane przez gitlab-runner exec z usługami z docker-compose.ci.yml. Jednym z prostszych wzorców jest “z góry odpalone” środowisko pomocnicze.
Typowy przepływ wygląda następująco:
- Developer uruchamia usługi wspierające:
docker-compose -f docker-compose.ci.yml up -d - Pipeline zakłada stałe hosty i porty, np.
DB_HOST=db,REDIS_HOST=cache, tak jak w zdalnym CI. - Lokalnie te same nazwy hostów są dostępne dzięki sieci Dockera – kontener z jobem i kontenery z usługami znajdują się w jednej sieci (lub są połączone przez aliasy).
Aby zapewnić wspólną sieć między docker-compose a kontenerami tworzonymi przez gitlab-runner, można użyć opcji --docker-network:
# tworzymy dedykowaną sieć dla lokalnego CI
docker network create local-ci-net
# uruchamiamy usługi pomocnicze w tej sieci
docker-compose -f docker-compose.ci.yml up -d --remove-orphans
# odpalamy job, podpinając go do tej samej sieci
gitlab-runner exec docker test_integration
--docker-network local-ci-netW pliku docker-compose.ci.yml trzeba wtedy zadbać o odpowiednie przypisanie usług do tej sieci:
Najczęściej zadawane pytania (FAQ)
Co to jest lokalne CI i czym różni się od „prawdziwego” serwera CI/CD?
Typowy obraz w głowie: stawiasz Jenkinsa czy GitLaba na własnym serwerze, konfigurujesz użytkowników, kolejki, webhooki – i mówisz, że masz lokalne CI. Tutaj chodzi o coś innego: o możliwość odpalenia pipeline’u na własnym laptopie, bez całego ciężkiego zaplecza.
Lokalne CI to lekka symulacja: narzędzia typu act (dla GitHub Actions) czy gitlab-runner exec biorą ten sam plik YAML, który używasz w centralnym CI, i uruchamiają joby na twojej maszynie. Nie potrzebujesz interfejsu webowego, hooków ani osobnego serwera – liczy się to, że możesz zrobić: „odpal pipeline lokalnie i zobacz logi”. Centralny CI nadal jest miejscem prawdziwych buildów i deployów, a lokalne CI służy do szybkiego wyłapywania błędów w konfiguracji.
Po co mi lokalne środowisko CI, skoro mam GitHub Actions / GitLab CI / Azure DevOps?
Sytuacja jest znajoma: drobna zmiana w YAML-u, push, kilka minut czekania w kolejce, a potem fail przez literówkę w nazwie zmiennej. Po kilku takich rundach zaczynasz bać się dotykać pipeline’u i kombinujesz „na ślepo”, kopiując konfiguracje z innych repo.
Lokalne CI skraca tę pętlę: poprawka w pipeline → jedna komenda lokalnie → wynik po kilkudziesięciu sekundach. Dzięki temu:
- łapiesz proste błędy (literówki, brakujące pakiety, złe ścieżki) zanim kod trafi na zdalny runner,
- możesz bez stresu refaktoryzować i upraszczać pipeline’y, zamiast je tylko „łatać”,
- odciążasz DevOpsów, bo mniej zmian kończy się zgłoszeniem „pipeline nie działa, pomóż”.
W praktyce oznacza to szybszy feedback dla developera i mniej przypadkowych „eksperymentów” na produkcyjnym CI.
Jakie elementy pipeline’u CI warto odwzorować lokalnie, a czego nie ma sensu?
Największy zysk daje zrobienie „lokalnych klonów” tych samych rzeczy, które i tak odpalają się przy każdym pushu. Czyli przede wszystkim:
- build i testy (kompilacja, testy jednostkowe/integracyjne, lint),
- lint i prosta statyczna analiza (np. ESLint, flake8, skaner Sonara w trybie offline),
- obsługa podstawowych sekretów i zmiennych środowiskowych (tokeny do rejestrów, klucze do sandboxów),
- generowanie lokalnych artefaktów (paczki, raporty, dokumentacja) w taki sam sposób jak na serwerze,
- strukturę jobów, tak by lokalny job odpowiadał konkretnemu jobowi w centralnym CI.
Dzięki temu konfiguracja, komendy i zależności są realnie sprawdzone przed pushem.
Z kolei odtwarzanie całych środowisk staging/production, skomplikowanych release’ów (canary, blue/green) czy ciężkich integracji z ERP-em ma marginalny sens na laptopie. Tego typu scenariusze i tak powinien obsługiwać centralny CI i dedykowane środowiska testowe, a lokalne CI ma pozostać lekkie i szybkie.
Jakie są minimalne wymagania sprzętowe do komfortowej pracy z lokalnym CI?
Moment, w którym odpalasz cały pipeline z testami integracyjnymi na laptopie, szybko pokazuje, czy sprzęt daje radę. Lokalnie obciążasz komputer podobnie jak zdalny runner – z tą różnicą, że czujesz to od razu na własnym CPU i RAM-ie.
Praktyczne minimum dla wygodnej pracy to:
- 16 GB RAM – szczególnie przy projektach w Javie, Node z wieloma modułami czy mikroserwisach; z 8 GB da się przeżyć, ale trzeba pilnować równoległości jobów,
- 4 fizyczne rdzenie CPU – kontenery, kompilacje i testy lubią kilka wątków,
- szybki SSD i sporo wolnego miejsca – obrazy dockera, cache zależności i artefakty potrafią zająć dziesiątki gigabajtów; regularne
docker system prunestaje się nawykiem.
Jeśli pipeline jest szczególnie ciężki, dobrym kompromisem bywa przerzucenie wykonania jobów na mocniejszą stację roboczą (dev-box) z dostępem przez SSH, traktując laptopa tylko jako sterownik.
Jakie narzędzia mogę wykorzystać do uruchamiania pipeline’ów CI lokalnie?
Kluczową rolę gra konteneryzacja, bo większość współczesnych CI i tak uruchamia joby w kontenerach. Stąd pierwszy fundament to Docker lub jego zamienniki (np. podman). To one zapewniają środowisko zbliżone do tego, co dzieje się na serwerze CI.
Na tym fundamencie można oprzeć konkretne narzędzia:
- GitHub Actions –
actpozwala lokalnie odpalać workflowy z katalogu.github/workflows, - GitLab CI –
gitlab-runner execpotrafi uruchamiać joby z.gitlab-ci.ymlna twojej maszynie, - Inne CI – często da się zmapować joby na zwykłe skrypty powłoki / Makefile / task runner (np.
npm scripts,invokew Pythonie), a CI tylko je wywołuje.
Im mniej „magii” specyficznej dla danego CI w samym YAML-u, a więcej logiki w skryptach wersjonowanych w repo, tym łatwiej to potem odpalić lokalnie.
Jak zorganizować repozytorium, żeby lokalne CI było łatwe do użycia dla zespołu?
Najczęstszy ból: ktoś „wie jak to odpalić”, ale reszta zespołu już nie i każdy odtwarza konfigurację na własną rękę. Po kilku sprintach lokalne CI umiera, bo nikt nie ma siły śledzić zmian.
Żeby temu zapobiec, przydaje się kilka prostych zasad:
- jedna, jasna komenda do odpalenia głównego pipeline’u lokalnie (np.
make local-ci), - możliwość uruchomienia pojedynczych jobów (np.
make test,make lint) bez grzebania w YAML-u, - spójne zarządzanie sekretami – np. plik
.env.locallublocal.secrets.yml, który jest opisany i wyłączony z gita, - krótka instrukcja typu
LOCAL_CI.mdalbo sekcja wREADMEz krokami: instalacja narzędzi, jak odpalać pipeline i jak diagnozować błędy.
Dzięki temu nowa osoba w zespole nie musi zgadywać, które komendy „mają zadziałać” – ma konkretną ścieżkę, jak przed pushem zweryfikować, że pipeline przechodzi lokalnie.






