NewsPortal Deploy - Docker, nginx, VPS

Partea 3 din 7

Actualizat 2026-05-24Sursă pe GitHub ↗Cod start

Parte 2 - docker-compose pentru stack complet

Dacă docker run vă lasă să porniți un container, docker compose vă lasă să orchestrați N containere ca un singur ansamblu - cu network-uri, volume-uri, env vars, dependențe.

Pentru News Portal aveți nevoie de 4 servicii:

  1. db - Postgres 16 cu volum persistent
  2. api - .NET 8 din Dockerfile-ul scris în secțiunea 01
  3. frontend - Angular 18 build static, servit de nginx
  4. proxy - reverse proxy nginx cu TLS, single point of entry

Toate într-un fișier docker-compose.yml, pornite cu o comandă: docker compose up -d --build.

Topologia stack-ului: proxy-ul e singurul cu porturi publice; api, frontend și db sunt vizibile doar în rețeaua Docker internă

Structura unui compose file

services:                # toate containerele
  numele_serviciu:
    image: imagine:tag   # SAU build:
    build:               # SAU image:
      context: ./folder
      dockerfile: Dockerfile
    ports:
      - "host:container"
    expose:
      - "container"      # doar intern, nu host
    environment:
      KEY: value
    volumes:
      - type-volume
    networks:
      - retea_custom
    depends_on:
      alt_serviciu:
        condition: service_healthy
    healthcheck:
      test: [...]
    restart: unless-stopped

volumes:                 # named volumes (persistente)
  db_data:

networks:                # custom networks
  reteaua_mea:
    driver: bridge

Compose pentru News Portal

services:
  db:
    image: postgres:16-alpine
    container_name: newsportal-db
    environment:
      POSTGRES_USER: ${DB_USER:-newsportal}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-newsportal_dev}
      POSTGRES_DB: ${DB_NAME:-newsportal}
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-newsportal}"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks: [newsportal_net]
    restart: unless-stopped

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: newsportal-api
    depends_on:
      db:
        condition: service_healthy
    environment:
      ConnectionStrings__DefaultConnection: "Host=db;Port=5432;Database=${DB_NAME:-newsportal};Username=${DB_USER:-newsportal};Password=${DB_PASSWORD:-newsportal_dev}"
      Jwt__Key: ${JWT_KEY:-DevKeyAtLeast32BytesLong!!}
      Jwt__Issuer: NewsPortal
      Jwt__Audience: NewsPortalUsers
      ASPNETCORE_ENVIRONMENT: Development
    expose: ["8080"]
    networks: [newsportal_net]
    restart: unless-stopped

  frontend:
    build:
      context: ./news-portal-app
      dockerfile: Dockerfile
    container_name: newsportal-frontend
    depends_on: [api]
    expose: ["80"]
    networks: [newsportal_net]
    restart: unless-stopped

  proxy:
    image: nginx:1.27-alpine
    container_name: newsportal-proxy
    depends_on: [api, frontend]
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certs:/etc/nginx/certs:ro
    networks: [newsportal_net]
    restart: unless-stopped

volumes:
  db_data:

networks:
  newsportal_net:
    driver: bridge

Conceptele importante

Networks: bridge custom vs default

Default, compose creează un network bridge cu numele <folder>_default. Toate serviciile sunt automat conectate. Service discovery se face prin DNS: serviciul api e accesibil de către alte containere ca http://api:8080. Numele containerului = hostname-ul DNS.

Când definiți explicit networks: [newsportal_net], aveți control mai mare (puteți separa rețele - ex db pe rețeaua sa, api pe rețeaua sa, doar api e “bridge” între ele). În lab folosim un singur network simplu, dar în producție e idiomatic să izolați.

expose: vs ports:

  • expose: ["8080"] - portul e disponibil doar în cadrul rețelei Docker. Alte containere îl pot accesa, host-ul nu.
  • ports: ["80:80"] - mapează portul host:container. Accesibil din afara host-ului (de pe altă mașină, internet).

În compose-ul de mai sus:

  • db are expose: 5432 (implicit din imagine, nu trebuie repetat). Niciun container nu poate ajunge la DB de pe internet - doar din rețeaua Docker.
  • api are expose: 8080. Doar frontend și proxy ajung la el.
  • frontend are expose: 80. Doar proxy ajunge la el.
  • proxy are ports: 80, 443. Punct unic de intrare publică.

Ăsta e principiul “single point of entry”: tot traficul intră prin proxy. Dacă cineva scanează host-ul, nu vede DB sau API direct - vede doar nginx-ul.

Volumes: bind vs named

volumes:
  - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro    # bind mount (path local -> path container)
  - db_data:/var/lib/postgresql/data               # named volume (gestionat de Docker)
  - logs:/app/logs:rw                              # named volume scriabil
Tip Pro Contra Când folosiți
Bind (./local:/container) Vedeți modificările în real-time Path-ul exact e expus config files, dev hot-reload
Named (name:/container) Portabile, gestionate de Docker Mai greu de inspectat DB data, persistent state
tmpfs (/tmp:size=50m) Numai în RAM, dispar la restart Volatil Cache, secrets temporare

Suffix :ro (read-only) e bun pentru config files - containerul nu trebuie să scrie acolo.

depends_on cu healthcheck

depends_on: simplu garantează doar ordinea de pornire, nu că dependința e gată. Postgres pornește în 2-3s dar nu acceptă conexiuni încă - dacă API-ul încearcă să se conecteze prea devreme, crash.

condition: service_healthy așteaptă până când healthcheck-ul dependinței trece de la starting la healthy. Pentru asta dependința trebuie să aibă healthcheck: definit:

db:
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U newsportal"]
    interval: 5s        # rula la fiecare 5s
    timeout: 3s         # daca nu raspunde in 3s, esuat
    retries: 5          # 5 esuari consecutive -> unhealthy
    start_period: 10s   # ignora primele 10s (startup time)

Acum api așteaptă până db răspunde la pg_isready. Pornirea poate dura 30s la primul build, dar e robustă.

Healthcheck dependency chain: api pornește doar după db: healthy; proxy doar după api + frontend started

.env substitution

Compose face substituție automat din fișierul .env de lângă docker-compose.yml:

POSTGRES_PASSWORD: ${DB_PASSWORD:-newsportal_dev}

Sintaxa ${VAR:-default} = “ia VAR din env, sau folosește default dacă nu e setată”.

.env (gitignored!):

DB_USER=newsportal
DB_PASSWORD=O_PAROLA_PUTERNICA
DB_NAME=newsportal
JWT_KEY=AltSecretGeneratCuOpenssl

.env.example (commit-uit):

DB_USER=newsportal
DB_PASSWORD=schimba_ma
DB_NAME=newsportal
JWT_KEY=schimba_ma_min_32_bytes

Workflow-ul: cp .env.example .env, completați valorile reale, rulați compose. Niciodată nu commit-ați .env real!

Dockerfile pentru frontend Angular

Stage 1 build cu node, stage 2 servire static cu nginx:

FROM node:20-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci                # ci e mai rapid si determinist decat install

COPY . .
RUN npm run build -- --configuration production

FROM nginx:1.27-alpine
COPY --from=build /app/dist/news-portal-app/browser/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

nginx.conf din folderul news-portal-app/ (lângă Dockerfile):

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # SPA fallback: orice URL care nu e fisier real -> index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    location ~* \.(js|css|woff|woff2|svg|png|jpg|jpeg|gif|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

try_files e crucial pentru Angular SPA: rutele client-side (ex /articles/123/edit) NU există ca fișiere pe server. Default, nginx ar întoarce 404. try_files zice: “încearcă file, dacă nu fall back la index.html”. Angular preia controlul și afișează ruta corectă.

Pornire + verificare (pe server)

Când ajungeți pe server (după setup-ul din secțiunea 04 + provisionarea de cont), pașii sunt:

# 1. Copiati template-ul .env in fisierul .env real si completati parolele
cp .env.example .env
nano .env             # DB_PASSWORD, JWT_KEY, etc.

# 2. Build + pornire (3-5 min cu cache rece)
docker compose up -d --build

Niciun cert de generat manual. nginx-ul cursului face TLS termination cu Let’s Encrypt automat pentru subdomeniul vostru. Compose-ul vostru NU include un container proxy - api-ul vostru se atașează la studlab_net (rețeaua cursului) și nginx-ul cursului face proxy <subdomeniul-vostru>.student-dev.ro -> student-<username>:8080.

Compose-ul de mai sus (cu serviciu proxy propriu și certurile self-signed) e util doar dacă vreți să rulați totul standalone pe un laptop / VPS personal. Pentru deploy pe student-dev.ro folosiți varianta simplificată din docker-deploy - doar db + api, fără proxy și fără frontend în compose.

Output-ul tipic:

[+] Running 5/5
 - Network newsportal_net      Created
 - Container newsportal-db        Started
 - Container newsportal-api       Started
 - Container newsportal-frontend  Started
 - Container newsportal-proxy     Started

Verificați:

docker compose ps
# arata toate cu STATUS = Up

docker compose logs -f api
# trebuie sa apara: "Now listening on: http://[::]:8080"

Browser: https://localhost (acceptați warning self-signed). Login: admin@newsportal.com / Admin@123.

Troubleshooting

Problemă Cauza probabilă Fix
connection refused la api ASPNETCORE_URLS lipsă sau setat la localhost setați http://+:8080 în env compose
db unhealthy Postgres nu primește conexiuni în start_period măriți start_period: 30s
frontend 404 pe rute Angular nginx fără try_files adăugați try_files $uri $uri/ /index.html;
502 Bad Gateway la /api/ api containerul a crashat docker compose logs api vă arată de ce
relation "..." does not exist DB-ul nu a fost inițializat docker compose down -v && docker compose up -d (șterge volume!)
bind: address already in use port 80/443 ocupat pe host opriți IIS/Apache/alt nginx, sau schimbați mapping în compose

În secțiunea următoare configurăm nginx-ul proxy pentru reverse proxy + TLS.