Jak zbudować własne środowisko testowe z Docker Compose: baza danych, backend i frontend w jednym pliku

0
155
1.5/5 - (2 votes)

Nawigacja:

Po co własne środowisko testowe z Docker Compose zamiast „na żywym” systemie

Ryzyka testowania bez izolacji

Testowanie aplikacji webowej bez izolowanego środowiska kończy się zwykle tym samym: konflikty wersji, „śmieci” w systemie i trudne do odtworzenia błędy. Na jednym komputerze potrafią wylądować równocześnie trzy wersje Javy, dwa serwery baz danych, kilka wersji Node.js i jeszcze parę losowych serwisów. Po kilku miesiącach nikt nie wie, co można bezpiecznie odinstalować, a co rozwali istniejące projekty.

Brak izolacji podnosi też ryzyko kosztownych pomyłek. Przykładowy klasyk: developer przypadkiem podłącza się z lokalnego backendu do produkcyjnej bazy danych, myśląc, że to instancja testowa. Jeden nieuważny truncate czy skrypt migracyjny i pojawia się poważny problem biznesowy zamiast niewinnego testu. Nawet jeśli są kopie zapasowe, czas i nerwy całego zespołu lecą w kosmos.

Do tego dochodzi bałagan w portach i konfiguracjach. Jeśli każda usługa jest instalowana ręcznie, łatwo o konflikt portów i niejasne błędy typu „port już używany” albo „nie można odnaleźć serwera bazy danych”. Odtworzenie warunków na innym komputerze – czy to nowy laptop w zespole, czy maszyna CI – zajmuje zaskakująco dużo czasu, bo każdy krok jest powtarzany ręcznie i trochę inaczej.

Co daje Docker Compose w codziennej pracy

Docker Compose rozwiązuje większość tych problemów jednym plikiem tekstowym. Zamiast instalować osobno bazę danych, backend i frontend, wystarczy opisać je jako trzy usługi w pliku docker-compose.yml. Dzięki temu:

  • środowisko jest powtarzalne – ten sam plik działa na dowolnej maszynie z Dockerem;
  • czas startu środowiska to często kilkanaście sekund, a nie godziny konfiguracji serwerów;
  • czyszczenie po testach to jedno polecenie docker compose down -v, zamiast ręcznego usuwania pakietów i katalogów;
  • każdy członek zespołu ma identyczne wersje usług, co minimalizuje efekt „u mnie działa”.

Compose spina wszystko w logiczny zestaw. Możesz uruchomić bazę, backend i frontend jednym poleceniem, a zatrzymać tak samo jednym. Zależności między usługami, nazwy hostów, wolumeny z danymi – to wszystko jest opisane deklaratywnie w jednym pliku, bez skakania po kilku różnych konfiguracjach.

Ręczne odpalanie usług vs jeden plik Compose

Ręczne startowanie każdego serwisu to strata czasu, która rośnie razem z projektem. Dla samej bazy danych i prostego backendu można jeszcze żyć z dwoma skryptami run.sh. Ale gdy dojdzie frontend, cache, lokalny broker wiadomości czy dodatkowe usługi pomocnicze, robi się z tego pajęczyna zależności.

Przy podejściu „na skróty” typowy dzień pracy zaczyna się od sekwencji: włącz bazę, uruchom backend, uruchom frontend, sprawdź porty, doładuj zmienne środowiskowe. W Compose wszystko to jest pakowane w jedną definicję, a całą sekwencję odpala pojedyncza komenda docker compose up. Dodatkowo Compose pamięta, które obrazy zostały zbudowane, jakie wolumeny istnieją, i nie trzeba tego pilnować ręcznie.

Różnica z perspektywy czasu i kosztu jest duża zwłaszcza przy onboardingach. Nowa osoba w zespole nie musi spędzać dwóch dni na ustawianiu środowiska; pobiera repozytorium, uruchamia Compose i ma taki sam zestaw serwisów jak reszta. To bezpośrednie oszczędności – mniej godzin pracy zmarnowanych na walkę z konfiguracją.

Dlaczego takie środowisko opłaca się nawet przy małych projektach

Na małym projekcie pokusa jest prosta: „po co mi Docker, to tylko jeden backend i baza, zrobię wszystko lokalnie”. Kłopot w tym, że większość małych projektów albo rośnie, albo za pół roku wraca w zmienionej formie. I wtedy dawna „szybka konfiguracja” staje się kulą u nogi, bo nikt nie pamięta, jak to działało.

Nawet przy prostym API różnica sprowadza się często do kilkunastu linijek w pliku Compose. W zamian dostaje się:

  • łatwe uruchomienie na innym komputerze (także na tanim VPS-ie do testów z klientem),
  • oddzielony stan bazy danych dla danego projektu,
  • możliwość szybkiego odtwarzania „czystej” bazy bez ręcznego kasowania tabel.

Compose ułatwia też eksperymenty. Chcesz sprawdzić projekt na innej wersji bazy albo z inną konfiguracją? Zmieniasz jedną linijkę obrazu, robisz docker compose up --build i gotowe. Bez instalowania dodatkowych binarek i kombinowania z menedżerami wersji.

Porównanie z innymi opcjami: Vagrant, chmura, minikube

Dla porządku warto zestawić Docker Compose z innymi podejściami. Vagrant i wirtualne maszyny oferują dobrą izolację, ale są cięższe zasobowo i wolniej się uruchamiają. Do projektów stricte webowych, gdzie wystarcza izolacja na poziomie kontenera, Docker jest zwykle lżejszy i szybszy.

Gotowe środowiska w chmurze (np. zintegrowane z CI/CD) są wygodne, lecz kosztują. Stawianie osobnego środowiska dla każdego developera w chmurze szybko generuje opłaty, a przy mniejszych zespołach to zbędny luksus. Compose uruchomiony lokalnie jest praktycznie darmowy – płacisz tylko za moc maszyny, którą i tak posiadasz.

Minikube i Kubernetes mają sens, gdy zespół planuje docelowo klaster K8s i ma wiele usług, skalowanie, rolling updates. Dla zwykłego środowiska testowego trzech serwisów konfiguracja Kubernetesa to ciężka artyleria. Compose jest prostszy, bardziej zrozumiały i szybszy do opanowania, a jednocześnie pokrywa większość potrzeb typowego projektu webowego.

Fundamenty, które trzeba ogarnąć przed pierwszym docker-compose.yml

Minimum o Dockerze: obraz, kontener, port, wolumen, sieć

Bez zrozumienia kilku podstawowych pojęć praca z Docker Compose będzie frustrująca. Na start wystarczy krótki słownik:

  • Obraz (image) – szablon systemu plików i konfiguracji, z którego uruchamiane są kontenery. Można go porównać do „zamrożonego” systemu z aplikacją.
  • Kontener – działająca instancja obrazu. Jeden obraz można uruchomić wiele razy jako różne kontenery.
  • Port – wejście/wyjście sieciowe kontenera. Udostępniając port (np. 8080), umożliwia się komunikację z usługą z zewnątrz.
  • Wolumen (volume) – trwałe miejsce na dane dla kontenerów. Po usunięciu kontenera dane na wolumenie nadal istnieją.
  • Sieć (network) – wirtualna sieć, w której kontenery mogą się widzieć po nazwach usług zamiast adresów IP.

Kluczowa różnica w stosunku do „gołego” systemu to niemutowalność obrazów. Obraz jest budowany raz, z konkretną wersją środowiska, a kontenery startują na jego podstawie. Zmiany w kontenerze (np. nowe pliki) znikają po jego usunięciu, jeśli nie są zapisane na wolumenie. Dlatego konfiguracje długoterminowe przenosi się do Dockerfile lub do definicji Compose.

Czym jest Docker Compose i jak działa względem „czystego” Dockera

Docker Compose to narzędzie, które pozwala definiować i zarządzać wieloma kontenerami jako jednym „projektem”. Zamiast wywoływać docker run dla każdej usługi z masą parametrów, opisuje się wszystkie usługi w pliku YAML, a Compose tłumaczy ten opis na konkretne komendy Dockera.

Na poziomie technicznym Compose korzysta z tego samego Daemona Dockera. Różnica polega na tym, że:

  • zapamiętuje konfigurację całego zestawu w jednym miejscu,
  • potrafi tworzyć sieci i wolumeny według deklaracji,
  • umożliwia restart całego środowiska jedną komendą.

Dla dewelopera ważne jest, że logika uruchamiania jest deklaratywna. Plik docker-compose.yml opisuje, jak ma wyglądać środowisko (jakie usługi, na jakich portach, z jakimi zmiennymi). Compose zajmuje się resztą.

Wersje Compose: v2 vs v3 w środowisku deweloperskim

Specyfikacja pliku Compose funkcjonuje w kilku wersjach (2, 2.4, 3, 3.9). Różnice dotyczą głównie integracji z Docker Swarm oraz dostępnych opcji. Do lokalnego środowiska testowego najczęściej wystarcza wersja 3.x, bo jest szeroko wspierana i dobrze udokumentowana.

W pliku na górze pojawia się pole version. Przykładowo version: "3.9" mówi Compose, której specyfikacji używać. Dla typowego zestawu „baza danych + backend + frontend” różnice między 3.7 a 3.9 są marginalne. Ważniejsze jest, by trzymać się jednej wersji w całym projekcie i nie mieszać stylów konfiguracji.

Jeśli używany Docker Desktop jest aktualny, spokojnie można się oprzeć na wersji 3.8 lub 3.9. Dla prostoty dalsze przykłady będą korzystać z version: "3.9", ale w większości przypadków zadziałają także na niższych wersjach 3.x.

Sprawdzenie instalacji Dockera i Compose

Przed pierwszym podejściem do Compose najlepiej upewnić się, że wszystko działa. Minimalny zestaw testów:

  • docker --version – pokazuje wersję Dockera; jeśli polecenie nie jest znane, instalacja jest niekompletna.
  • docker run hello-world – uruchamia testowy obraz; jeśli widoczny jest komunikat powitalny, daemon działa.
  • docker compose version lub docker-compose --version – w zależności od wersji Dockera. Nowe instalacje używają podkomendy docker compose.

Jeżeli hello-world nie działa, zwykle problem leży w uprawnieniach (brak dostępu do demona Dockera) albo w tym, że usługa Dockera nie jest uruchomiona. Z punktu widzenia efektywności warto rozwiązać te podstawowe kłopoty od razu, zanim zacznie się budować pełne środowisko.

Mapa pojęć: services, networks, volumes, depends_on

Plik docker-compose.yml opiera się na kilku kluczowych sekcjach:

  • services – definicje poszczególnych usług (kontenerów): jaka baza, jaki backend, jaki frontend.
  • networks – definicje sieci, w których będą działać usługi. W prostym przypadku jedna sieć „wewnętrzna” wystarczy.
  • volumes – definicje wolumenów dla trwałych danych (np. dane bazy).
  • depends_on – informacja, że jedna usługa powinna startować po innej (np. backend po bazie).

Compose tworzy domyślną sieć dla usług, ale jawne zdefiniowanie własnej sieci (np. app-network) poprawia czytelność i pozwala później łatwiej kontrolować komunikację między usługami. Wolumeny natomiast sprawiają, że dane bazy nie znikają przy każdym restarcie kontenera – co przy dłuższych testach jest absolutnie kluczowe.

Programistka pisząca kod na laptopie w nowoczesnym biurze z monitorami
Źródło: Pexels | Autor: Christina Morillo

Projekt referencyjny: proste środowisko DB + backend + frontend

Założenia przykładowej aplikacji

Dla zobrazowania konfiguracji warto przyjąć prosty, ale realistyczny projekt referencyjny:

  • Backend (API) – REST API napisane w Node.js (dla uproszczenia), udostępniające np. listę użytkowników.
  • Baza danych – PostgreSQL jako popularna i darmowa baza relacyjna.
  • Frontend – prosta aplikacja SPA (np. React/Vue) odpalana w trybie dev lub jako statyczne pliki serwowane z Nginxa.

Cel: wszystkie trzy elementy powinny być uruchamiane i zatrzymywane jednym poleceniem Compose. Backend łączy się z bazą po nazwie usługi db, a frontend odwołuje się do backendu przez http://api:3000 albo przez port hosta, w zależności od trybu.

Podział na trzy usługi: db, api, web

W Compose nadawanie prostych, zwięzłych nazw usług skraca konfigurację i ułatwia debugowanie. Najczęściej spotykany schemat:

  • db – baza danych (PostgreSQL lub MySQL),
  • api – backend z logiką biznesową i REST API,
  • web – frontend aplikacji (dev server lub serwer statyczny).

Wewnętrzna komunikacja wygląda wtedy następująco:

  • backend łączy się z bazą używając hosta db i portu 5432 (domyślny dla PostgreSQL),
  • frontend łączy się z backendem albo przez nazwę hosta api wewnątrz sieci Compose, albo przez port wystawiony na hosta (np. http://localhost:3000).

Takie nazwy mają jeszcze jedną zaletę: łatwo je zapamiętać i używać w konfiguracji. Bez opisywania każdemu nowemu developerowi, że baza siedzi na 192.168.x.y z jakimś nietypowym portem.

Struktura katalogów w repozytorium

Uporządkowana struktura katalogów przyspiesza pracę całego zespołu. Dla omawianego projektu wystarczy taki układ:

Przykładowy układ plików dla prostego stacka

Najprostszy schemat, który nie robi bałaganu i skaluje się w czasie:

projekt/
├─ docker-compose.yml
├─ .env
├─ backend/
│  ├─ Dockerfile
│  ├─ package.json
│  └─ src/
├─ frontend/
│  ├─ Dockerfile
│  ├─ package.json
│  └─ src/
└─ db/
   ├─ init/
   │  └─ 001_init.sql
   └─ backups/

Najważniejsze punkty:

  • docker-compose.yml leży w katalogu głównym repozytorium – jedna komenda docker compose up z root-a projektu i wszystko startuje.
  • .env także w root-cie – zmienne środowiskowe wspólne dla całego projektu (np. porty, hasła do DB w dev).
  • Każdy serwis ma własny katalog z Dockerfile i kodem – backend nie miesza się z frontendem.
  • Katalog db/init trzyma skrypty SQL inicjalizujące bazę (np. tworzenie schematu, użytkowników).

Taki podział pozwala później odpalać każdy serwis osobno (np. lokalny backend bez kontenera), a jednocześnie nadal korzystać z jednego Compose dla całego zestawu.

Konwencje nazw portów i usług

Chaotyczne porty to klasyczny generator konfliktów. Lepiej od razu przyjąć prosty wzorzec:

  • Frontend dev server: 5173 (Vite), 3000 (React), 8080 (Vue) – w Compose można te domyślne porty po prostu przepuścić na hosta.
  • Backend: 3000 lub 8080 na hoście, niezależnie od frameworka, by nie mylić ludzi w zespole.
  • Baza: domyślny port DB (5432 dla Postgresa) wewnątrz sieci Compose, a na hosta port wystawiać tylko gdy to rzeczywiście potrzebne.

Dobry kompromis: backend i frontend wystawione na hosta, baza dostępna tylko wewnątrz sieci Compose. Dzięki temu klient nie jest w stanie przez przypadek dostać się do testowej bazy spoza zespołu, a deweloperzy i tak mają wszystko pod ręką.

Definicja bazy danych w Docker Compose – konfiguracja usług DB

Wybór obrazu Postgresa i podstawowe zmienne środowiskowe

Dla środowiska testowego nie ma sensu komplikować. Wystarczy oficjalny obraz Postgresa z Docker Hub i kilka zmiennych:

  • POSTGRES_USER – użytkownik, który będzie używany przez backend,
  • POSTGRES_PASSWORD – hasło tego użytkownika,
  • POSTGRES_DB – nazwa bazy, której backend będzie używał.

Przykładowa definicja usługi bazy danych w docker-compose.yml:

version: "3.9"

services:
  db:
    image: postgres:16-alpine
    container_name: projekt-db
    environment:
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
      POSTGRES_DB: app_db
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d app_db"]
      interval: 5s
      timeout: 3s
      retries: 10

Niewielki obraz postgres:alpine w zupełności wystarcza do testów i oszczędza pasmo przy pierwszym pobraniu. Montowanie katalogu ./db/init do /docker-entrypoint-initdb.d pozwala automatycznie odpalać skrypty SQL przy pierwszym starcie bazy.

Wolumeny dla bazy i ich konsekwencje

Bez wolumenu każde docker compose down z opcją -v kasuje dane, a nawet zwykły restart może skończyć się utratą stanu przy niepoprawnej konfiguracji. Ta linia rozwiązuje problem:

volumes:
  db-data:

oraz odniesienie w usłudze:

services:
  db:
    volumes:
      - db-data:/var/lib/postgresql/data

Dzięki temu dane bazy siedzą w named volume db-data. Można wyczyścić środowisko, a potem świadomie usunąć dane poleceniem:

docker volume rm projekt_db-data

W środowisku deweloperskim często przydaje się drugi tryb: trzymanie danych bazy w zwykłym katalogu w repozytorium. To trochę mniej wydajne, ale ułatwia debugowanie (podgląd plików, backup przez zwykłą kopię katalogu):

services:
  db:
    volumes:
      - ./db/data:/var/lib/postgresql/data

Na początku prostszy będzie named volume; bind mount na katalog ./db/data można dorzucić później, gdy pojawi się potrzeba dokładnego podglądu fizycznych plików.

Inicjalizacja schematu i danych startowych

Automatyczna inicjalizacja bazy oszczędza dziesiątki minut każdemu nowemu członkowi zespołu. Mechanizm Postgresa jest prosty: wszystkie skrypty SQL w katalogu /docker-entrypoint-initdb.d są odpalane przy pierwszym stworzeniu instancji bazy.

Przykład minimalnego skryptu db/init/001_init.sql:

CREATE TABLE IF NOT EXISTS users (
  id SERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
);

INSERT INTO users (email) VALUES
  ('anna@example.com'),
  ('jan@example.com')
ON CONFLICT DO NOTHING;

Po starcie Compose baza od razu ma tabelę i kilka rekordów. Backend może od początku zwracać sensowne dane, bez ręcznego klikania w narzędzia typu pgAdmin.

Bezpieczeństwo w dev: hasła, porty, backupy

W dev nie ma potrzeby używać superlosowych haseł, ważniejsze jest, by nie trzymać ich na stałe w repozytorium. Bezpieczny i wygodny kompromis:

  • Hasła i nazwy baz trzymać w pliku .env (wykluczonym z Gita),
  • w docker-compose.yml odwoływać się do nich przez ${NAZWA_ZMIENNEJ}.

Przykładowy .env (lokalny, niecommitowany):

POSTGRES_USER=app_user
POSTGRES_PASSWORD=dev_secret
POSTGRES_DB=app_db
API_PORT=3000
WEB_PORT=5173

Fragment Compose:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}

Backup w dev można robić najprostszą możliwą metodą, bez narzędzi klasy enterprise:

docker exec -t projekt-db pg_dump -U app_user app_db > ./db/backups/backup.sql

Przy awarii czy eksperymentach wystarczy przywrócić dane przez psql < backup.sql wewnątrz kontenera.

Czarno-białe biurko programisty z laptopem, kubkiem i notesem
Źródło: Pexels | Autor: Negative Space

Backend w kontenerze – od Dockerfile po usługę w Compose

Minimalny backend w Node.js – założenia

Backend w przykładzie to proste REST API na Expressie, wystawiające endpoint /users, który czyta z Postgresa. Nie musi być produkcyjnie zoptymalizowany – priorytetem jest szybki start i spójna konfiguracja.

Załóżmy, że katalog backend/ zawiera standardowy projekt Node z package.json, a aplikacja startuje komendą npm run dev lub npm start.

Dockerfile backendu – wersja „tania, ale wystarczająca”

Najprostszy sensowny Dockerfile dla Node 18 w środowisku dev:

# backend/Dockerfile
FROM node:18-alpine

WORKDIR /usr/src/app

# Instalacja zależności w dwóch krokach
COPY package*.json ./
RUN npm install

# Kod aplikacji
COPY . .

# Zmienna PORT z domyślną wartością
ENV PORT=3000

EXPOSE 3000

CMD ["npm", "run", "dev"]

Dla środowiska testowego nie ma sensu od razu budować wieloetapowych obrazów z minimalizacją rozmiaru. Obraz node:18-alpine jest wystarczająco lekki, a Dockerfile pozostaje czytelny dla każdego z zespołu.

Definicja usługi API w docker-compose.yml

Teraz trzeba spiąć backend z Postgres i z siecią aplikacji. Przykładowa usługa:

services:
  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: projekt-api
    restart: unless-stopped
    environment:
      NODE_ENV: development
      PORT: ${API_PORT}
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: ${POSTGRES_USER}
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: ${POSTGRES_DB}
    ports:
      - "${API_PORT}:3000"
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - ./backend:/usr/src/app
      - /usr/src/app/node_modules
    networks:
      - app-network

Kilka istotnych szczegółów:

  • build.context wskazuje na katalog backend/ – Compose buduje obraz bezpośrednio z kodu projektu.
  • ports mapuje port z pliku .env na port kontenera (3000) – backend będzie dostępny na http://localhost:API_PORT.
  • depends_on z condition: service_healthy sprawia, że backend startuje dopiero, gdy healthcheck bazy przejdzie pomyślnie.
  • volumes montują katalog z kodem – zmiany w plikach backendu od razu są widoczne w kontenerze (hot reload).

Hot reload backendu bez ręcznego przebudowywania obrazu

Przy pracy nad backendem kluczowe jest, by nie przebudowywać obrazu przy każdej zmianie. W Node najprościej dodać do projektu nodemon i użyć go jako komendy startowej w dev:

{
  "scripts": {
    "dev": "nodemon src/index.js"
  },
  "devDependencies": {
    "nodemon": "^3.0.0"
  }
}

Kontener backendu używa tej komendy w CMD. Dzięki wolumenowi:

volumes:
  - ./backend:/usr/src/app

każda zmiana kodu na hoście od razu wywołuje restart nodemona w kontenerze. Z punktu widzenia dewelopera różnica względem lokalnego Node jest minimalna – a środowisko i tak działa na tych samych obrazach, co u reszty zespołu.

Konfiguracja połączenia z bazą wewnątrz sieci Compose

Backend nie łączy się z bazą po localhost, tylko po nazwie usługi db. W kodzie Node może to wyglądać tak (przykład z biblioteką pg):

const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST || 'db',
  port: process.env.DB_PORT || 5432,
  user: process.env.DB_USER || 'app_user',
  password: process.env.DB_PASSWORD || 'app_password',
  database: process.env.DB_NAME || 'app_db',
});

module.exports = pool;

Dzięki temu backend działa zarówno w kontenerze (gdzie host to db), jak i poza nim, jeśli nadpisze się zmienne środowiskowe (np. DB_HOST=localhost i wystawiony port bazy na hoście).

Frontend w kontenerze – kiedy osobny kontener ma sens

Dev server vs. build produkcyjny

Frontend ma dwa typowe tryby pracy:

  • Tryb developerski – narzędzie typu Vite/webpack dev server, z hot module replacement, często na porcie 5173/3000/8080.
  • Tryb produkcyjny – statyczne pliki zbudowane raz (npm run build) i serwowane przez prosty serwer HTTP (np. Nginx).

W środowisku testowym wystarczy najczęściej dev server wewnątrz kontenera, bo pozwala szybko iterować nad UI. Osobny kontener Nginxa ma sens dopiero, gdy zespół chce wierniejszego odzwierciedlenia produkcji lub testuje kwestie cache / headerów.

Dockerfile dla frontendu z Vite/React

Przykład lekkiego Dockerfile dla frontendu opartego na Vite (podobnie będzie dla CRA czy Vue CLI):

# frontend/Dockerfile
FROM node:18-alpine

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm install

COPY . .

ENV PORT=5173
EXPOSE 5173

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

Parametr --host 0.0.0.0 jest istotny – bez niego serwer dev będzie nasłuchiwał tylko na localhost w kontenerze i nie będzie dostępny z hosta.

Usługa frontendu w Compose – tanio i skutecznie

Definicja usługi web w docker-compose.yml może wyglądać tak:

services:
  web:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: projekt-web
    restart: unless-stopped
    environment:
      NODE_ENV: development
      VITE_API_URL: http://api:${API_PORT}
    ports:
      - "${WEB_PORT}:5173"
    depends_on:
      - api
    volumes:
      - ./frontend:/usr/src/app
      - /usr/src/app/node_modules
    networks:
      - app-network

Kilka praktycznych punktów:

Front i backend w jednej sieci – komunikacja między kontenerami

Frontend w kontenerze nie powinien strzelać do API po http://localhost:3000, bo z jego perspektywy localhost to sam kontener, a nie host czy backend. W sieci Compose usługi widzą się po swoich nazwach, więc naturalnym adresem API jest http://api:3000.

Przy dev serverach (Vite, webpack) wygodnie użyć zmiennej środowiskowej w momencie startu:

services:
  web:
    environment:
      VITE_API_URL: http://api:3000

W kodzie frontendu (np. React + Vite):

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';

export async function fetchUsers() {
  const res = await fetch(`${API_URL}/users`);
  if (!res.ok) {
    throw new Error('API error');
  }
  return res.json();
}

Dzięki temu:

  • w Compose frontend gada z backendem po http://api:3000,
  • lokalnie (bez Dockera) wciąż można użyć http://localhost:3000, nadpisując zmienną np. w .env.local.

Hot reload frontendu z wolumenami

Tak jak przy backendzie, front nie może wymagać przebudowywania obrazu po każdym kliknięciu. Wolumen na kod załatwia sprawę:

services:
  web:
    volumes:
      - ./frontend:/usr/src/app
      - /usr/src/app/node_modules

Pierwszy wolumen montuje katalog z projektem, drugi chroni node_modules w kontenerze przed nadpisaniem pustym katalogiem z hosta (gdy masz świeży checkout repo). W pakiecie dostajesz typowy dev experience: zapisujesz plik JSX/TSX i po sekundzie widzisz zmianę w przeglądarce, choć wszystko leci przez Dockera.

Kontener frontendu jako opcja, nie obowiązek

Czasami tańszym w utrzymaniu wariantem jest uruchamianie tylko bazy i backendu w Compose, a front trzymać lokalnie, bez kontenera. Zwłaszcza gdy zespół UI intensywnie modyfikuje bundler, instalacje Node i toolingi. W takim wariancie:

  • docker-compose odpala db i api,
  • frontend odpala się z hosta (npm run dev),
  • API jest konfigurowane w frontcie jako http://localhost:API_PORT.

To dobry kompromis, gdy backendowcy chcą powtarzalnego środowiska, a frontendowcy wolą pełną swobodę na swoich maszynach. Można też trzymać gotową definicję web w Compose, ale uruchamiać ją tylko wtedy, gdy ktoś akurat tego potrzebuje.

Jeden plik docker-compose.yml – spójne spięcie trzech usług

Pełny przykład docker-compose.yml

Kiedy każdy z elementów działa osobno, pora poskładać je w jeden, nadal czytelny plik. Przykładowa wersja docker-compose.yml dla całego mini-stosu:

version: "3.9"

services:
  db:
    image: postgres:16-alpine
    container_name: projekt-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10
    networks:
      - app-network

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: projekt-api
    restart: unless-stopped
    environment:
      NODE_ENV: development
      PORT: ${API_PORT}
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: ${POSTGRES_USER}
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: ${POSTGRES_DB}
    ports:
      - "${API_PORT}:3000"
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - ./backend:/usr/src/app
      - /usr/src/app/node_modules
    networks:
      - app-network

  web:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: projekt-web
    restart: unless-stopped
    environment:
      NODE_ENV: development
      VITE_API_URL: http://api:${API_PORT}
    ports:
      - "${WEB_PORT}:5173"
    depends_on:
      - api
    volumes:
      - ./frontend:/usr/src/app
      - /usr/src/app/node_modules
    networks:
      - app-network

volumes:
  db-data:

networks:
  app-network:
    driver: bridge

Taki plik nie jest jeszcze „enterprise-grade”, ale spełnia kluczowy cel: jedno polecenie docker compose up startuje całą aplikację z bazą, backendem i frontendem, bez ręcznej konfiguracji na każdej maszynie.

Struktura katalogów projektu – ważniejsza niż się wydaje

Compose staje się wygodny dopiero wtedy, gdy katalogi są poukładane w sposób przewidywalny. Minimalny, praktyczny układ:

.
├── backend/
│   ├── Dockerfile
│   ├── package.json
│   └── src/
├── frontend/
│   ├── Dockerfile
│   ├── package.json
│   └── src/
├── db/
│   ├── init/
│   │   └── 001_init.sql
│   └── backups/
├── docker-compose.yml
└── .env

Taki podział pozwala w miarę bezboleśnie dorzucać kolejne usługi (np. Redis, worker) i nadal utrzymać porządek. Każdy moduł ma swój Dockerfile, więc nie ma mieszania zależności Node/Pythona/Java w jednym miejscu.

Minimalne profile startowe – tylko to, czego faktycznie używasz

Jeśli czasem potrzebna jest sama baza z backendem (np. do testów integracyjnych w CI), a front ma nie startować, można użyć profiles w Compose. Pozwalają grupować usługi bez rozbijania pliku na kilka części. Przykład:

services:
  db:
    profiles: ["default", "ci"]

  api:
    profiles: ["default", "ci"]

  web:
    profiles: ["default"]

Domyślnie (docker compose up) startują wszystkie usługi oznaczone profilem default. W CI można z kolei odpalić tylko backend z bazą:

docker compose --profile ci up -d db api

Ogranicza to czas buildów i nie marnuje zasobów na front, który i tak nie jest wtedy używany.

Obsługa zależności między usługami bez magii

depends_on rozwiązuje kolejność startu, ale nie sprawia cudów. Gdy backend zależy od tego, by DB było naprawdę gotowe (nie tylko w trakcie startu), solidniejszy jest healthcheck bazy, który już został dodany. W prostszych projektach to w zupełności wystarcza.

Jeśli API nadal czasem pada przy starcie (np. przy dłuższym inicjowaniu bazy), można dodać krótki retry w samym backendzie. Typowy wzór „tanio, ale skutecznie” to pętla startowa, która kilka razy próbuje połączyć się z DB, zanim zgłosi błąd.

Skalowanie poziome usług w Compose – tylko tam, gdzie ma sens

Compose pozwala podnieść kilka replik backendu jednym parametrem, choć w środowisku dev/test rzadko jest to konieczne. Jeśli jednak zespół chce pobawić się load-balancingiem czy sprawdzić zachowanie przy większym obciążeniu, można podejść do tego w prosty sposób:

docker compose up --scale api=3

Do sensownego rozkładania ruchu przyda się wtedy albo dodatkowy reverse proxy (np. Nginx/Traefik jako osobna usługa w Compose), albo obciążenie generowane bezpośrednio z sieci Compose (np. narzędzie testowe odpalone w tym samym bridgu).

Przydatne skróty i aliasy dla zespołu

Im mniej poleceń trzeba pamiętać, tym chętniej ludzie korzystają z Compose. Dobry, tani trik to proste aliasy w Makefile lub skryptach npm, które opakowują dłuższe komendy. Przykład z Makefile:

up:
tdocker compose up -d

down:
tdocker compose down

logs:
tdocker compose logs -f --tail=100

ps:
tdocker compose ps

Deweloper, który dopiero dołącza do projektu, nie musi wtedy uczyć się całej składni Dockera pierwszego dnia. Wystarczy, że odpali make up i ma kompletne środowisko z trzema usługami.

Konwencje nazewnicze i porty – małe rzeczy, które oszczędzają czas

Przy kilku projektach na jednej maszynie konflikty portów szybko zaczynają irytować. Rozsądnym kompromisem jest wybranie zakresu portów per projekt (np. 3000–3100 dla projektu A, 3200–3300 dla B) i trzymanie się go w .env:

API_PORT=3001
WEB_PORT=3002

W repozytorium można trzymać plik .env.example z domyślnymi wartościami i krótkim komentarzem, a każdy lokalnie kopiuje go do .env i po swojemu modyfikuje porty, gdy coś koliduje:

cp .env.example .env

Taka prosta konwencja w praktyce oszczędza całe godziny na debugowaniu „czemu u mnie nie działa”, bo struktura i porty są przewidywalne na większości maszyn w zespole.

Rozszerzanie środowiska o kolejne usługi bez przepisywania całości

Gdy podstawowy zestaw DB + API + frontend zacznie być codziennym narzędziem, naturalnym krokiem jest dorzucenie prostych dodatków: cache (Redis), kolejki (RabbitMQ), narzędzia pomocnicze (MailHog, MinIO). Z perspektywy Compose wygląda to bardzo podobnie jak definicja bazy:

services:
  redis:
    image: redis:7-alpine
    container_name: projekt-redis
    ports:
      - "6379:6379"
    networks:
      - app-network

Backend dostaje wtedy dodatkową zmienną REDIS_HOST=redis i tyle. Nie trzeba zmieniać struktury projektu ani budować skomplikowanego środowiska orkiestracji. Jeden plik Compose wciąż jest centrum dowodzenia.

Najczęściej zadawane pytania (FAQ)

Dlaczego warto budować lokalne środowisko testowe w Docker Compose zamiast na „gołym” systemie?

Środowisko „na goło” szybko zamienia się w miks wersji i zależności, którego nikt nie kontroluje. Instalujesz kilka Javy, różne bazy, parę Node’ów – po kilku miesiącach nie wiadomo, co można bezpiecznie usunąć, a co rozwali inne projekty. Do tego dochodzą konflikty portów i błędy, które trudno odtworzyć na innym komputerze.

Compose zamyka wszystko w jednym pliku i izolowanych kontenerach. Dzięki temu konfiguracja jest powtarzalna, łatwo przenośna między maszynami i nie śmieci w systemie. Efekt w relacji do wysiłku jest bardzo korzystny: raz przygotowany plik docker-compose.yml oszczędza godziny przy każdym kolejnym projekcie, rekrutacji czy zmianie laptopa.

Czy Docker Compose ma sens przy małym projekcie z jedną bazą i backendem?

Tak, nawet przy bardzo małym API z jedną bazą Compose szybko się zwraca. Zwykle kończy się na kilkunastu linijkach konfiguracji, a w zamian dostajesz osobny stan bazy dla projektu, powtarzalne uruchamianie środowiska na dowolnym komputerze i łatwe czyszczenie danych po testach.

Problem z małymi projektami jest taki, że za pół roku albo urosną, albo ktoś wróci do nich „na szybko”. Bez Compose odtwarzanie środowiska zajmuje nieproporcjonalnie dużo czasu. Z plikiem docker-compose.yml wystarczy jedno polecenie, żeby odzyskać działające środowisko, bez grzebania w starych notatkach.

Jakie są główne korzyści z używania Docker Compose na co dzień dla bazy, backendu i frontendu?

Compose pozwala opisać bazę danych, backend i frontend w jednym pliku i uruchamiać je jednym poleceniem. Oszczędza to czas na codziennym „klikaninie”: zamiast ręcznie startować każdą usługę, sprawdzać porty i zmienne środowiskowe, robisz docker compose up i masz cały zestaw gotowy do pracy.

Dodatkowo:

  • wszyscy w zespole mają identyczne wersje usług (mniej „u mnie działa”),
  • sprzątanie po testach to jedno polecenie docker compose down -v,
  • środowisko łatwo przenieść na maszynę CI lub tani VPS, bez pisania osobnych skryptów instalacyjnych.

To szybki sposób na „pół-profesjonalne” środowisko bez kosztów chmury czy ciężkich wirtualek.

Jakie podstawy Dockera muszę znać, żeby zacząć z Docker Compose?

Na start wystarczy ogarnięcie kilku pojęć: obraz (image), kontener, port, wolumen (volume) i sieć (network). Obraz to szablon, z którego uruchamiane są kontenery. Kontener to działająca instancja obrazu. Porty wystawiają usługi na zewnątrz, wolumeny przechowują trwałe dane (np. bazę), a sieć pozwala kontenerom gadać między sobą po nazwach usług.

Kluczowe jest zrozumienie, że obrazy są „niemutowalne” – zmiany w kontenerze znikają po jego skasowaniu, jeśli nie są na wolumenie. Dlatego trwałe rzeczy (instalacje, konfiguracje) pakujesz do Dockerfile i definicji Compose, a nie „doinstalowujesz ręcznie w kontenerze”. To minimalna wiedza, która już pozwala sensownie korzystać z Compose.

Czym Docker Compose różni się od zwykłego Dockera (docker run)?

Docker to silnik, który uruchamia kontenery, a Compose jest nakładką, która spina wiele kontenerów w jedno logiczne środowisko. Zamiast pisać długie komendy docker run dla każdej usługi, opisujesz wszystko deklaratywnie w jednym pliku YAML, a Compose tłumaczy to na konkretne polecenia Dockera.

Praktycznie oznacza to:

  • mniej powtarzania opcji (porty, wolumeny, zmienne) w konsoli,
  • łatwy restart całego środowiska jednym poleceniem,
  • automatyczne tworzenie sieci i wolumenów według opisu w pliku.

To wygodniejsze i tańsze czasowo niż utrzymywanie zestawu skryptów bash z docker run na każdy projekt.

Czy lepiej użyć Docker Compose, czy od razu wejść w Kubernetes/minikube?

Dla typowego środowiska testowego z trzema–czterema usługami Compose jest zwykle najtańszą opcją pod względem czasu nauki i utrzymania. Minikube i Kubernetes mają sens, gdy celujesz w docelowy klaster K8s, wiele usług, skalowanie i zaawansowane deploymenty. Wtedy inwestycja w złożoność ma uzasadnienie biznesowe.

Jeśli potrzebujesz po prostu lokalnego środowiska do developmentu i testów, Compose będzie szybszy do opanowania i lżejszy dla sprzętu. W praktyce często wygląda to tak: na początek Docker Compose na laptopie, a dopiero przy większej skali – przejście na Kubernetesa w środowiskach testowych i produkcyjnych.

Docker Compose vs Vagrant/maszyny wirtualne vs środowisko w chmurze – co wybrać najtaniej?

Vagrant i klasyczne maszyny wirtualne dają dobrą izolację, ale są zasobożerne i wolno się uruchamiają. Dla lekkich aplikacji webowych kontenery Dockera są szybsze i mniej obciążają sprzęt, więc bardziej opłacalne na zwykłym deweloperskim laptopie.

Środowiska w chmurze są wygodne, lecz generują stałe koszty – osobne środowisko testowe dla każdego developera szybko podbija rachunki. Compose odpalasz lokalnie, płacąc tylko „w prądzie i RAM-ie”, który już masz. Jeśli chcesz coś „dla klienta”, możesz ten sam plik Compose wrzucić na tani VPS i mieć demo bez inwestowania w rozbudowaną infrastrukturę.

Co warto zapamiętać

  • Testowanie „na żywym” systemie bez izolacji generuje bałagan w wersjach, trudne do odtworzenia błędy i realne ryzyko pomyłek typu przypadkowe operacje na produkcyjnej bazie.
  • Jeden plik docker-compose.yml pozwala spiąć bazę, backend i frontend w powtarzalne środowisko, które da się uruchomić i wyczyścić pojedynczymi komendami, bez ręcznego grzebania w systemie.
  • Docker Compose drastycznie skraca czas startu pracy i onboarding nowych osób – zamiast dwóch dni konfiguracji środowiska wystarcza pobranie repozytorium i odpalenie docker compose up.
  • Nawet małe projekty zyskują: mają odseparowany stan bazy, łatwy „reset” danych i możliwość szybkiego przeniesienia środowiska na inny komputer lub tani VPS testowy.
  • Compose ułatwia eksperymenty z wersjami usług – zmiana obrazu bazy czy konfiguracji to zwykle korekta jednej linijki i ponowne zbudowanie, bez instalowania kolejnych narzędzi lokalnie.
  • W porównaniu z Vagrantem, pełnymi VM-kami czy minikube, Docker Compose zapewnia lżejszą, tańszą i szybszą w utrzymaniu alternatywę dla typowych projektów webowych bez rozbudowanego klastra.
  • Żeby wykorzystać Compose bez frustracji, trzeba ogarnąć podstawowe pojęcia Dockera (obraz, kontener, port, wolumen, sieć) – to mała inwestycja czasu, która mocno zwraca się w codziennej pracy.