Docker solves "works on my machine", but in production you need a controlled entry point: Nginx in front of the containers, with TLS terminated in one place, isolated networks and closed ports. A practical, step-by-step guide with docker-compose.
Why Docker + Nginx
- Isolation and reproducibility — each app with its own dependencies, identical on dev and prod.
- A single entry point — Nginx terminates TLS once and routes to containers by domain/path.
- Small attack surface — only 80/443 exposed; databases stay on the internal Docker network, no public port.
- Simple operations — restart policy, healthcheck, updates via image re-pull.
Architecture
The internet reaches only Nginx (80/443). Nginx proxies to the app containers over an internal Docker bridge network. The database is on the same network but publishes NO port to the outside.
Step 1 — Install Docker
- Install Docker Engine + the compose plugin (Debian/Ubuntu): packages
docker-ce docker-ce-cli containerd.io docker-compose-pluginfrom the official Docker repo. - Verify:
docker run --rm hello-worldanddocker compose version. - Create a dedicated network:
docker network create web(user-defined network = DNS resolution between containers by name).
Step 2 — docker-compose for the app
Example docker-compose.yml with an app + Postgres, healthcheck and restart policy:
- services.app:
image,restart: unless-stopped,expose: ["8080"](NOTports— do not publish),networks: [web],healthcheckwithtest: curl -f http://localhost:8080/health. - services.db:
image: postgres:16,volumes: [dbdata:/var/lib/postgresql/data],networks: [web], NOports— reachable only internally. - Secrets (passwords) via
environmentfrom a.envfile (not baked into the image), orsecrets. - Start:
docker compose up -d; check:docker compose ps,docker compose logs -f app.
Step 3 — Nginx reverse proxy
Nginx (on the host or as a container on the same web network) proxies to the container by service name:
upstreamorproxy_pass http://app:8080;(the name "app" resolves via the Docker network DNS).- Correct headers:
proxy_set_header Host $host;X-Real-IP $remote_addr;X-Forwarded-For $proxy_add_x_forwarded_for;X-Forwarded-Proto $scheme; - WebSocket (if needed):
proxy_http_version 1.1;+Upgrade/Connection. - Multi-app routing: by
server_name(different domains) orlocation /app2/toproxy_pass http://app2:3000;.
Step 4 — TLS with Let's Encrypt
- Redirect
80 -> 443for all human traffic; leave only/.well-known/acme-challenge/open on 80. - Certificate:
certbot --webroot(or acertbotcontainer in compose) with automatic renewal. - In the 443 block:
ssl_certificate/ssl_certificate_key,ssl_protocols TLSv1.2 TLSv1.3.
Step 5 — Hardening
- No needless ports: only Nginx publishes 80/443. DB and app use
expose, notports. - Non-root in the container:
user:in compose orUSERin the Dockerfile; avoid running as root. - Read-only + no-new-privileges:
read_only: true,security_opt: [no-new-privileges:true],cap_drop: [ALL]. - Resource limits:
deploy.resources.limits(CPU/RAM) so one container cannot take down the host. - Security headers at Nginx: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP.
- Small images + updates:
-slim/alpinebases, scan withdocker scout/ Trivy, re-pull regularly.
Automatic Let's Encrypt
The certificate should not be renewed by hand. Certbot installs a systemd timer (certbot.timer) that runs certbot renew twice a day and only renews what expires in under 30 days.
- Reload nginx after renewal:
certbot renew --deploy-hook "nginx -s reload". - Test without real issuance:
certbot renew --dry-run. - In Docker: either a
certbotcontainer with a volume shared with nginx (renew loop), or thenginx-proxy+acme-companionpattern that issues and renews certificates per container automatically, based on theVIRTUAL_HOST/LETSENCRYPT_HOSTvariables.
GeoIP + nftables firewall
Country blocking can happen at two levels. At the nginx level (GeoIP2 module + map $geoip2_country_code) you reply with 403. It is more efficient to drop packets in nftables before they reach nginx — that also blocks scans, not just HTTP requests.
- Put country prefixes into an nftables set (interval):
set blocked_cc { type ipv4_addr; flags interval; }. - Rule in the input chain:
ip saddr @blocked_cc drop(or the inverse whitelist: accept only allowed countries). - Prefix source: per-country lists (e.g. ipdeny.com) or a GeoIP database; a cron script reloads the set daily.
- Persist in
/etc/nftables.conf; check withnft list ruleset. - Blacklist vs whitelist: blacklist (block a few high-risk countries) is safe; whitelist (only your country + a few) is aggressive — it may block legitimate bots/CDNs, so test first.
CrowdSec on Nginx logs
CrowdSec reads the nginx logs (access.log / error.log), applies scenarios and blocks hostile IPs — with collective intelligence (IPs reported across the whole CrowdSec network).
- Acquisition: in
/etc/crowdsec/acquis.yamladd the nginx log paths (typenginx). - Collections:
cscli collections install crowdsecurity/nginx crowdsecurity/base-http-scenarios crowdsecurity/http-cve— detects brute-force, path scanning, flood, malicious user-agents, exploit attempts. - Bouncer (enforces the block):
crowdsec-firewall-bouncerputs IPs into an nftables set (crowdsec-blacklists) → drop; orcrowdsec-nginx-bouncerwhich replies 403 at the nginx level. - Operations:
cscli decisions list,cscli metrics,cscli alerts list; enroll in the CrowdSec console for a dashboard + community blocklists (CTI). - vs fail2ban: CrowdSec has richer scenarios and collaborative reputation; fail2ban is still fine for simple, local rules.
Logging and operations
- Logs:
docker compose logs; for production, thejson-filedriver withmax-size/max-fileor ship to a log stack (Loki). - App update:
docker compose pull && docker compose up -d(recreates only what changed). - Backup: the volumes (
dbdata) —docker run --rm -v dbdata:/data ...or a scheduledpg_dump. - Health:
docker compose psshows healthcheck status; the restart policy brings a crashed container back.
Conclusion
The Nginx + Docker pattern gives you reproducible deploys, a single TLS entry point and a minimal attack surface — from a single VPS to multiple nodes. We design it, secure it and monitor it for you.