#nginx#certbot#tls#self-hosting#docker

Dockerized nginx with Let's Encrypt for self-hosted nutrition apps

If Caddy isn't your style: an nginx + certbot config that's actually maintainable.

Why nginx instead of Caddy

We default to Caddy, but plenty of people prefer nginx — usually because:

  • They already run nginx for everything else.
  • They want explicit TLS control rather than Caddy’s automatic-everything.
  • They need module-specific features (custom logging, ngx_brotli, etc.).

Fine. Here’s a clean nginx setup for the same use case.

Layout

/srv/nutrition-proxy/
├── docker-compose.yml
├── nginx/
│   ├── conf.d/
│   │   └── nutrition.conf
│   ├── snippets/
│   │   ├── ssl-params.conf
│   │   └── security-headers.conf
│   └── nginx.conf
├── certbot/
│   ├── conf/
│   └── www/
└── secrets/
    └── htpasswd

docker-compose.yml

services:
  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/snippets:/etc/nginx/snippets:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
      - ./secrets/htpasswd:/etc/nginx/htpasswd:ro
    networks: [proxy-net]
    depends_on: [off-mirror]

  certbot:
    image: certbot/certbot:v2.10.0
    restart: unless-stopped
    volumes:
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    entrypoint: >
      /bin/sh -c "trap exit TERM;
      while :; do certbot renew --webroot --webroot-path /var/www/certbot --quiet;
      sleep 12h & wait $${!}; done;"

  off-mirror:
    image: openfoodfacts/openfoodfacts-server:nightly
    restart: unless-stopped
    expose: ["8080"]
    volumes: [off-data:/var/data/off]
    networks: [proxy-net]

networks:
  proxy-net:

volumes:
  off-data:

nginx.conf snippets

snippets/ssl-params.conf:

ssl_protocols TLSv1.3 TLSv1.2;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;

ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 9.9.9.9 valid=60s;
resolver_timeout 2s;

snippets/security-headers.conf:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "interest-cohort=()" always;

conf.d/nutrition.conf:

server {
    listen 80;
    server_name nutrition.example.org;

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

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

server {
    listen 443 ssl;
    http2 on;
    server_name nutrition.example.org;

    ssl_certificate     /etc/letsencrypt/live/nutrition.example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/nutrition.example.org/privkey.pem;
    include /etc/nginx/snippets/ssl-params.conf;
    include /etc/nginx/snippets/security-headers.conf;

    auth_basic "nutrition";
    auth_basic_user_file /etc/nginx/htpasswd;

    client_max_body_size 50m;

    location /off/ {
        proxy_pass http://off-mirror: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 https;
        proxy_buffering off;
    }

    location / {
        return 200 "OK\n";
        add_header Content-Type text/plain;
    }

    access_log /var/log/nginx/nutrition.log;
    error_log  /var/log/nginx/nutrition.err;
}

First cert issuance

docker compose up -d nginx
docker compose run --rm certbot certonly \
  --webroot --webroot-path /var/www/certbot \
  -d nutrition.example.org \
  --email editor@example.org --agree-tos --no-eff-email
docker compose exec nginx nginx -s reload

Subsequent renewals are handled by the certbot container’s loop.

Verifying

curl -I https://nutrition.example.org/
# Expected: HTTP/2 401 with auth challenge

curl -I -u admin:correct-password https://nutrition.example.org/off/api/v2/product/0
# Expected: HTTP/2 200 + content-type application/json

Run an external scan:

docker run --rm -ti drwetter/testssl.sh https://nutrition.example.org/

Should report A or A+.

Auto-renewal sanity check

docker compose run --rm certbot renew --dry-run

If this errors, fix it now. Real renewal failures are surprisingly common.

What’s missing

This config does not include:

  • HTTP/3 / QUIC. nginx supports it as of 1.25. We don’t run it because the gain is marginal for this kind of traffic and the operational complexity isn’t worth it. Switch to nginx-quic image and add listen 443 quic if you want it.
  • Brotli. Gzip is fine for JSON. ngx_brotli is a separate module; if you compile your own image, sure.
  • A WAF. ModSecurity in front of nginx is overkill for a home nutrition stack and a maintenance burden.

When to switch back to Caddy

Honestly: most of the time, after writing this much YAML and conf, you’ll wonder why you didn’t just use Caddy. We’ve migrated personal services in both directions twice. The honest answer is: use what your hands already know.

References

  • nginx docs: nginx.org/en/docs/
  • certbot: certbot.eff.org
  • Mozilla SSL config generator: ssl-config.mozilla.org
  • testssl.sh: testssl.sh
  • Caddy reverse proxy alternative