#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 quicif 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