← Terug naar tutorials

Docker-apps beveiligen met Let’s Encrypt en Nginx: HTTPS setup & debugging

dockernginxlet's encrypthttpsssl/tlsreverse proxycertbotdocker composebeveiligingdebugging

Docker-apps beveiligen met Let’s Encrypt en Nginx: HTTPS setup & debugging

Deze tutorial laat je stap voor stap zien hoe je Docker-applicaties veilig achter Nginx zet met een Let’s Encrypt TLS-certificaat (HTTPS), inclusief een degelijke debugging-aanpak wanneer het misgaat. Je krijgt echte commando’s, uitleg over de onderliggende concepten, en praktische checks om 80/443, certificaten, headers en reverse proxy-problemen te verifiëren.

Doel: meerdere Docker-apps (bijv. app1 en app2) bereikbaar maken via https://app1.jouwdomein.nl en https://app2.jouwdomein.nl, met automatische certificaatvernieuwing.


Inhoud

  1. Voorwaarden & uitgangspunten
  2. DNS en firewall: de basis die vaak vergeten wordt
  3. Architectuurkeuzes: Nginx op de host vs. in Docker
  4. Benadering A: Nginx + Certbot op de host (aanrader voor eenvoud)
  5. Benadering B: Alles in Docker (Nginx + Certbot containers)
  6. Debugging: systematisch problemen oplossen
  7. Checklist: productie-hardening

Voorwaarden & uitgangspunten

Je hebt nodig:

We gaan uit van twee apps:


DNS en firewall: de basis die vaak vergeten wordt

DNS instellen

Maak A-records aan die naar het publieke IP van je server wijzen:

Controleer DNS:

dig +short app1.jouwdomein.nl A
dig +short app2.jouwdomein.nl A

Of:

nslookup app1.jouwdomein.nl

Firewall/poort forwarding

Let’s Encrypt gebruikt meestal de HTTP-01 challenge: Let’s Encrypt moet jouw server kunnen bereiken op poort 80 om een token op te halen. Daarna gebruik je HTTPS op 443.

Controleer lokaal of Nginx luistert:

sudo ss -tulpn | grep -E ':80|:443'

Als je UFW gebruikt:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status

Als je op cloudniveau security groups gebruikt (AWS/GCP/Azure/Hetzner), open daar ook 80/443.


Architectuurkeuzes: Nginx op de host vs. in Docker

Er zijn twee gangbare manieren:

  1. Nginx op de host + Certbot op de host

    • Simpel, weinig moving parts.
    • Makkelijk te debuggen met systemd logs.
    • Nginx kan Docker-apps bereiken via localhost (als je ports publish’t) of via Docker bridge IP’s.
  2. Nginx in Docker + Certbot in Docker

    • Alles containerized.
    • Iets complexer: volumes voor certificaten, shared webroot, reload van Nginx.
    • Debugging vereist meer containerkennis.

In deze tutorial werk ik Benadering A volledig uit (aanrader). Daarna geef ik een complete werkbare Benadering B.


Benadering A: Nginx + Certbot op de host (aanrader voor eenvoud)

4.1 Docker-apps op een intern netwerk

Maak een mapstructuur:

mkdir -p ~/stack
cd ~/stack

Voorbeeld docker-compose.yml voor twee apps (simpel gehouden). Let op: je kunt apps ook zonder compose draaien; compose maakt het overzichtelijk.

cat > docker-compose.yml <<'EOF'
services:
  app1:
    image: node:20-alpine
    command: sh -c "node -e \"require('http').createServer((req,res)=>res.end('app1 ok')).listen(3000)\""
    expose:
      - "3000"
    networks:
      - internal

  app2:
    image: nginx:alpine
    volumes:
      - ./app2-html:/usr/share/nginx/html:ro
    expose:
      - "80"
    networks:
      - internal

networks:
  internal:
    driver: bridge
EOF

Maak content voor app2:

mkdir -p app2-html
echo "app2 ok" > app2-html/index.html

Start:

docker compose up -d
docker compose ps

Belangrijk concept: expose publiceert niet naar de host; het maakt de poort alleen zichtbaar binnen Docker-netwerken. Nginx op de host kan containers niet direct via expose bereiken, tenzij je extra routing doet. Daarom heb je twee opties:

Voor Benadering A kiezen we meestal: publish naar localhost zodat host-Nginx upstreams kan bereiken zonder ze publiek te maken.

Pas compose aan:

cat > docker-compose.yml <<'EOF'
services:
  app1:
    image: node:20-alpine
    command: sh -c "node -e \"require('http').createServer((req,res)=>res.end('app1 ok')).listen(3000)\""
    ports:
      - "127.0.0.1:3000:3000"

  app2:
    image: nginx:alpine
    volumes:
      - ./app2-html:/usr/share/nginx/html:ro
    ports:
      - "127.0.0.1:8080:80"
EOF

Herstart:

docker compose up -d

Test lokaal op de server:

curl -i http://127.0.0.1:3000
curl -i http://127.0.0.1:8080

Je ziet app1 ok en app2 ok.

Waarom 127.0.0.1 binding?
Omdat je app-poorten niet publiek wil exposen. Nginx is de enige publieke ingang. Binding op 127.0.0.1 zorgt dat alleen de host zelf (en dus Nginx) erbij kan.


4.2 Nginx installeren en basisconfig

Installeer Nginx:

sudo apt update
sudo apt install -y nginx

Controleer status:

sudo systemctl status nginx --no-pager

Check config syntax:

sudo nginx -t

4.3 HTTP reverse proxy werkend krijgen (zonder TLS)

We maken twee server blocks. Gebruik de standaard Debian/Ubuntu layout:

app1 (HTTP)

sudo tee /etc/nginx/sites-available/app1.jouwdomein.nl.conf > /dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name app1.jouwdomein.nl;

    # Handig voor debugging: log apart
    access_log /var/log/nginx/app1.access.log;
    error_log  /var/log/nginx/app1.error.log;

    location / {
        proxy_pass http://127.0.0.1:3000;

        # Belangrijke headers voor reverse proxy
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
EOF

app2 (HTTP)

sudo tee /etc/nginx/sites-available/app2.jouwdomein.nl.conf > /dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name app2.jouwdomein.nl;

    access_log /var/log/nginx/app2.access.log;
    error_log  /var/log/nginx/app2.error.log;

    location / {
        proxy_pass http://127.0.0.1:8080;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
EOF

Activeer sites:

sudo ln -s /etc/nginx/sites-available/app1.jouwdomein.nl.conf /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/app2.jouwdomein.nl.conf /etc/nginx/sites-enabled/

Test en reload:

sudo nginx -t
sudo systemctl reload nginx

Test extern (vanaf je laptop):

curl -i http://app1.jouwdomein.nl
curl -i http://app2.jouwdomein.nl

Als dit werkt, is je reverse proxy correct en kun je TLS toevoegen. Als dit niet werkt, ga dan direct naar Debugging (met name DNS/poorten en 502).


4.4 Let’s Encrypt certificaat aanvragen met Certbot (nginx plugin)

Installeer Certbot + Nginx plugin:

sudo apt install -y certbot python3-certbot-nginx

Vraag certificaten aan. Je kunt meerdere domeinen in één certificaat zetten, maar vaak is per app ook prima. Hier doen we per stuk voor duidelijkheid:

sudo certbot --nginx -d app1.jouwdomein.nl
sudo certbot --nginx -d app2.jouwdomein.nl

Wat gebeurt er onder water?

Controleer Nginx config opnieuw:

sudo nginx -t
sudo systemctl reload nginx

Test HTTPS:

curl -I https://app1.jouwdomein.nl
curl -I https://app2.jouwdomein.nl

Bekijk certificaatdetails:

echo | openssl s_client -connect app1.jouwdomein.nl:443 -servername app1.jouwdomein.nl 2>/dev/null | openssl x509 -noout -issuer -subject -dates

4.5 HSTS, security headers en TLS-instellingen

Certbot maakt meestal een werkende TLS-config, maar productie-hardening is meer dan “slotje aan”.

HTTP naar HTTPS redirect expliciet maken

Soms wil je de HTTP-serverblock minimalistisch maken:

server {
    listen 80;
    listen [::]:80;
    server_name app1.jouwdomein.nl;
    return 301 https://$host$request_uri;
}

Maar let op: voor Let’s Encrypt renewals via HTTP-01 moet /.well-known/acme-challenge/ bereikbaar blijven. Certbot regelt dit doorgaans automatisch. Als je handmatig harden’t, zorg dan dat challenge requests niet stuk gaan (zie debugging-sectie).

Aanbevolen headers

Voeg in je HTTPS server block toe (niet in HTTP als je altijd redirect):

Voorbeeld (plaats in de server { listen 443 ssl; ... } block):

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# HSTS: pas aan als je zeker weet dat alles via HTTPS werkt
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;

Belangrijk: Zet HSTS pas aan als je zeker weet dat HTTPS overal werkt. Met includeSubDomains forceer je ook subdomeinen; dat kan pijnlijk zijn als je ergens nog HTTP gebruikt.

TLS-versies en ciphers

Moderne Nginx/Ubuntu defaults zijn meestal oké. Als je expliciet wil zijn:

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;

4.6 Automatische vernieuwing testen

Certbot installeert meestal een systemd timer. Check:

systemctl list-timers | grep certbot
systemctl status certbot.timer --no-pager

Test renewal (dry-run):

sudo certbot renew --dry-run

Bekijk logs:

sudo tail -n 200 /var/log/letsencrypt/letsencrypt.log

Benadering B: Alles in Docker (Nginx + Certbot containers)

Deze aanpak is handig als je host zo “clean” mogelijk wil houden. Je draait:

Directory structuur

mkdir -p ~/proxy/{nginx,letsencrypt,webroot}
cd ~/proxy

Nginx config (Docker)

Maak ~/proxy/nginx/conf.d/apps.conf:

mkdir -p nginx/conf.d
cat > nginx/conf.d/apps.conf <<'EOF'
# HTTP: serve ACME challenge + redirect naar HTTPS
server {
    listen 80;
    server_name app1.jouwdomein.nl app2.jouwdomein.nl;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS app1
server {
    listen 443 ssl http2;
    server_name app1.jouwdomein.nl;

    ssl_certificate     /etc/letsencrypt/live/app1.jouwdomein.nl/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app1.jouwdomein.nl/privkey.pem;

    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        proxy_pass http://app1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# HTTPS app2
server {
    listen 443 ssl http2;
    server_name app2.jouwdomein.nl;

    ssl_certificate     /etc/letsencrypt/live/app2.jouwdomein.nl/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app2.jouwdomein.nl/privkey.pem;

    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        proxy_pass http://app2:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
EOF

Docker Compose voor proxy + apps

Maak docker-compose.yml:

cat > docker-compose.yml <<'EOF'
services:
  nginx:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./letsencrypt:/etc/letsencrypt
      - ./webroot:/var/www/certbot
    depends_on:
      - app1
      - app2

  app1:
    image: node:20-alpine
    command: sh -c "node -e \"require('http').createServer((req,res)=>res.end('app1 ok')).listen(3000)\""

  app2:
    image: nginx:alpine
    volumes:
      - ./app2-html:/usr/share/nginx/html:ro

  certbot:
    image: certbot/certbot:latest
    volumes:
      - ./letsencrypt:/etc/letsencrypt
      - ./webroot:/var/www/certbot
EOF

Start Nginx + apps:

mkdir -p app2-html
echo "app2 ok" > app2-html/index.html

docker compose up -d nginx app1 app2
docker compose ps

Certificaten aanvragen (webroot methode)

Voor app1:

docker compose run --rm certbot certonly \
  --webroot -w /var/www/certbot \
  -d app1.jouwdomein.nl \
  --email jij@jouwdomein.nl --agree-tos --no-eff-email

Voor app2:

docker compose run --rm certbot certonly \
  --webroot -w /var/www/certbot \
  -d app2.jouwdomein.nl \
  --email jij@jouwdomein.nl --agree-tos --no-eff-email

Reload Nginx zodat hij de nieuwe certs pakt:

docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload

Renewal (bijv. via cron op de host):

docker compose run --rm certbot renew
docker compose exec nginx nginx -s reload

Debugging: systematisch problemen oplossen

HTTPS + reverse proxy issues voelen vaak “mysterieus”, maar je kunt ze bijna altijd herleiden met een vaste volgorde:

  1. DNS → wijst naar juiste IP?
  2. Poort 80/443 → bereikbaar vanaf internet?
  3. Nginx → draait en serveert juiste server_name?
  4. Upstream → app bereikbaar vanaf Nginx?
  5. Certificaat → klopt domein/SNI/keten?
  6. App → vertrouwt proxy headers en proto?

6.1 DNS/poortproblemen

Symptomen

Checks

DNS:

dig +short app1.jouwdomein.nl

Poorten vanaf externe machine:

curl -I http://app1.jouwdomein.nl

Of met nc:

nc -vz app1.jouwdomein.nl 80
nc -vz app1.jouwdomein.nl 443

Op de server: luistert Nginx?

sudo ss -tulpn | grep -E ':80|:443'

Firewall:

sudo ufw status verbose
sudo iptables -S | head

Als je Nginx in Docker draait, check dan of Docker port mapping actief is:

docker ps --format 'table {{.Names}}\t{{.Ports}}'

6.2 Nginx-config fouten en reload issues

Symptomen

Checks

Syntax:

sudo nginx -t

Reload en status:

sudo systemctl reload nginx
sudo systemctl status nginx --no-pager

Logs:

sudo tail -n 200 /var/log/nginx/error.log
sudo tail -n 200 /var/log/nginx/app1.error.log

In Docker:

docker compose exec nginx nginx -t
docker compose logs --tail=200 nginx

Veelvoorkomende fout: dubbele listen 443 ssl; blocks met dezelfde server_name of een default server die “vangt” wat je niet verwacht. Check welke config geladen is:

sudo nginx -T | less

6.3 ACME challenge problemen (Let’s Encrypt)

Symptomen

Belangrijke concepten

Let’s Encrypt vraagt een URL op zoals:

http://app1.jouwdomein.nl/.well-known/acme-challenge/<token>

Die moet op poort 80 publiek bereikbaar zijn en exact de juiste content teruggeven.

Debug aanpak

Test of Nginx die path correct serveert (webroot methode):

curl -i http://app1.jouwdomein.nl/.well-known/acme-challenge/test

Als je webroot gebruikt, maak tijdelijk een testbestand:

sudo mkdir -p /var/www/certbot/.well-known/acme-challenge
echo OK | sudo tee /var/www/certbot/.well-known/acme-challenge/test
curl -i http://app1.jouwdomein.nl/.well-known/acme-challenge/test

Bij Docker-webroot:

mkdir -p ~/proxy/webroot/.well-known/acme-challenge
echo OK > ~/proxy/webroot/.well-known/acme-challenge/test
curl -i http://app1.jouwdomein.nl/.well-known/acme-challenge/test

Veelvoorkomende oorzaken:


6.4 502/504 errors: upstream niet bereikbaar

Symptomen

Checks

Test upstream vanaf de host (Benadering A):

curl -i http://127.0.0.1:3000
curl -i http://127.0.0.1:8080

Als dit faalt: je app draait niet of luistert op andere poort/interface.

Check containers:

docker compose ps
docker compose logs --tail=200 app1
docker compose logs --tail=200 app2

Als Nginx in Docker draait (Benadering B), test vanuit de Nginx container:

docker compose exec nginx sh -c "apk add --no-cache curl >/dev/null 2>&1 || true; curl -i http://app1:3000"
docker compose exec nginx sh -c "curl -i http://app2:80"

Veelvoorkomend: je proxy_pass wijst naar localhost in een container. In Docker betekent localhost “de container zelf”, niet de host en niet een andere container. Gebruik dan servicenaam (app1:3000) of host.docker.internal (niet overal standaard op Linux).


6.5 Redirect loops en mixed content

Symptomen

Oorzaak

Veel webapps detecteren het schema (http/https) op basis van headers. Als Nginx TLS terminaat, praat Nginx intern vaak HTTP met de app. Zonder X-Forwarded-Proto: https denkt de app dat het HTTP is en redirect terug naar HTTP of bouwt verkeerde absolute URLs.

Fix

Zorg dat je deze headers zet:

proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Soms moet je app expliciet “trust proxy” aanzetten (bijv. Express/Node):

app.set('trust proxy', true);

En configureer base URL / canonical URL in je app.

Test redirects:

curl -I http://app1.jouwdomein.nl
curl -I -L http://app1.jouwdomein.nl
curl -I https://app1.jouwdomein.nl

6.6 WebSockets en streaming

Symptomen

Nginx heeft extra headers nodig voor WebSockets:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Plaats dit in de relevante location block.

Ook timeouts kunnen belangrijk zijn:

proxy_read_timeout 3600s;
proxy_send_timeout 3600s;

Debug met logs en browser devtools. Test handshake:

curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Host: app1.jouwdomein.nl" \
  https://app1.jouwdomein.nl/ws

(De exacte path hangt af van je app.)


6.7 Certificaatketen, SNI en verkeerde certificaten

Symptomen

Checks

SNI test (belangrijk bij meerdere domeinen op één IP):

echo | openssl s_client -connect jouwdomein.nl:443 -servername app1.jouwdomein.nl 2>/dev/null | openssl x509 -noout -subject -issuer
echo | openssl s_client -connect jouwdomein.nl:443 -servername app2.jouwdomein.nl 2>/dev/null | openssl x509 -noout -subject -issuer

Controleer dat server_name correct is en dat elke server block de juiste ssl_certificate paden gebruikt.

Let op: als je een “default” server op 443 hebt met een ander certificaat, kan verkeer daar landen als server_name mismatcht.


Checklist: productie-hardening

Gebruik dit als eindcontrole:

Netwerk & exposure

TLS & certificaten

Nginx reverse proxy correctheid

Security headers

Observability


Extra: snelle “alles-in-één” diagnosecommando’s

Op de server (host-Nginx):

# 1) Nginx status + config
sudo systemctl status nginx --no-pager
sudo nginx -t

# 2) Luisterpoorten
sudo ss -tulpn | grep -E ':80|:443'

# 3) Certbot timers
systemctl list-timers | grep certbot

# 4) Upstreams (lokaal)
curl -i http://127.0.0.1:3000
curl -i http://127.0.0.1:8080

# 5) Externe endpoint (vanaf server zelf, test DNS + routing)
curl -I http://app1.jouwdomein.nl
curl -I https://app1.jouwdomein.nl

Afronding

Je hebt nu een complete setup om Docker-apps veilig via Nginx te publiceren met Let’s Encrypt HTTPS, inclusief een praktische debugging-methodiek voor de meest voorkomende problemen: DNS/poorten, ACME challenges, upstream 502/504, redirect loops en certificaat/SNI issues.

Als je wil, kan ik ook: