HostiServer
2026-09-26 11:18
Docker in Production: Reverse Proxy, SSL
📚 "Docker on a VPS" series, part 3 of 5:
Docker in production: reverse proxy, SSL, autostart
In the previous article, we brought the app, the database, and the cache together into one docker-compose.yml, and the stack comes up with a single command. But as it stands, it's still far from production-ready. Port 3000 is exposed directly to the internet, traffic only travels over HTTP, and a second project on the same VPS would immediately collide with the occupied port. And if the server reboots — say, after a scheduled kernel update — the stack won't come back on its own: you'd have to SSH in manually and run docker compose up -d.
This article closes three gaps at once. First, Nginx as a reverse proxy: one server, one IP, the standard ports 80 and 443, as many applications behind it as you like. Second, an SSL certificate from Let's Encrypt, free and auto-renewing. Third, a systemd unit that brings the whole stack back up after a server reboot with no human involved. As a bonus, we'll cover resource limits, so a container that spirals out of control doesn't take down the whole server, and log rotation, which otherwise eventually fills up the disk.
2. Nginx as a reverse proxy in Docker
2.1 Nginx as a separate service
Nginx gets added to docker-compose.yml as another service, and it's now the only one with ports exposed to the outside — 80:80 and 443:443. The ports: section gets removed from the app service: the application is no longer reachable directly from the internet, only through the proxy.
2.2 A shared network
Nginx and app stay on the same app-net network as before. Nginx resolves the name app:3000 exactly the way app itself resolved db:5432 in the previous article — through Compose's built-in DNS.
2.3 A minimal HTTP config to get started
Before you can get a certificate, you need a working HTTP config — this is exactly what Let's Encrypt will use to verify you actually own the domain:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Every one of these headers has a specific reason to be there: Host passes the app the original domain name, which would otherwise get lost behind the proxy; X-Real-IP passes the visitor's real IP address, since without it the app would only see the Nginx container's internal IP; X-Forwarded-Proto tells the app whether the request came over HTTP or HTTPS — it needs this, for instance, to build correct absolute links.
2.4 Dependency on app
The nginx service gets depends_on: [app] added, so Compose brings the app up before the proxy. If needed, you can add a service_healthy condition here too, if the app has an endpoint like /health that a healthcheck can poll.
3. SSL: Let's Encrypt + Certbot
3.1 A prerequisite — DNS
Before running Certbot, the domain needs to point at the server's IP address. You can check this right away:
dig +short example.com
The output should be exactly your VPS's IP. If the record hasn't propagated through DNS yet, Certbot is guaranteed to fail its check — and it's worth waiting rather than trying to debug something in the Nginx config.
3.2 Certbot on the host, not in a container
Certbot gets installed directly on the server rather than run as a separate Docker container — this is simpler for ongoing automatic renewal, and there's no need to route sockets or volumes back and forth. The most common approach is webroot: Certbot drops a test challenge file into a designated directory, and Nginx, with that directory mounted, serves it externally in response to Let's Encrypt's request.
3.3 The alternative — standalone
If you'd rather not set up webroot separately, there's a standalone mode: Certbot spins up its own temporary web server on port 80. For this, Nginx has to be stopped for roughly five seconds — acceptable for getting your first certificate, but inconvenient for regular renewal on a live production server.
3.4 Where the result ends up
After a successful issuance, the certificate and private key show up at /etc/letsencrypt/live/example.com/{fullchain.pem,privkey.pem}. This directory gets mounted inside the Nginx container as a bind mount, always in :ro (read-only) mode — Nginx needs to read the certificate, but should never be able to modify it.
4. The Nginx HTTPS config
4.1 Two server blocks
A working config consists of two blocks: the first listens on port 80 and immediately issues a return 301 https://$host$request_uri; redirect, the second listens on 443 and holds the actual SSL setup:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
4.2 Why TLSv1.2 and TLSv1.3, and nothing lower
ssl_protocols TLSv1.2 TLSv1.3; is the floor you shouldn't go below: TLS 1.0 and 1.1 are officially deprecated and fail modern security scanners. ssl_prefer_server_ciphers off; follows Mozilla's Intermediate profile recommendations — with modern clients, it's better to let the client choose the cipher rather than forcing the server's own order.
4.3 HSTS and other security headers
Strict-Transport-Security tells the browser to remember that this domain should only ever be visited over HTTPS, even if the user types the address without https://. The risk here is real: max-age=63072000 is two years, and if HTTPS suddenly becomes unavailable during that time (say, the certificate expired and wasn't renewed in time), browsers will simply refuse to fall back to HTTP. HSTS is worth turning on only once automatic certificate renewal has been tested and is actually working. X-Content-Type-Options and X-Frame-Options are additional, lower-risk headers against MIME-sniffing and clickjacking.
4.4 proxy_read_timeout
Nginx's default is 60 seconds, and that's enough for most APIs. It's worth raising only for specific long-running operations: generating a report, processing a large file, a long call to an external API. Raising the timeout globally "just in case" isn't a good idea — a backend task that's hung for some reason is better off dying after a minute than holding a connection open indefinitely.
4.5 Applying the config with no downtime
docker compose exec nginx nginx -s reload
nginx -s reload re-reads the configuration and certificates while keeping already-open connections alive — unlike a full container restart (docker compose restart nginx), which briefly drops every active connection.
5. Automatic SSL renewal
5.1 The certificate's lifetime
Let's Encrypt certificates are issued for 90 days. The certbot renew command checks every existing certificate and only renews the ones with fewer than 30 days left — meaning it's safe to run it daily, since it won't touch anything that doesn't need renewing yet.
5.2 Why a reload is needed after renewal
Nginx loads a certificate into memory once, at startup or on reload, and doesn't re-read the file from disk on its own. So even after a successful certbot renew, Nginx keeps serving the old certificate until it's explicitly told to reload the configuration.
5.3 The deploy hook
Certbot can run an arbitrary script right after a successful renewal — this is exactly where the reload gets placed:
certbot renew --deploy-hook "docker compose exec nginx nginx -s reload"
5.4 The cron job
Running the check once a day is enough, ideally at night under low load:
0 3 * * * certbot renew --quiet --deploy-hook "docker compose exec nginx nginx -s reload"
The --quiet flag suppresses output when no renewal was needed — otherwise system mail or the cron log fills up with the same message every single day.
5.5 Confirming the mechanism actually works
certbot renew --dry-run
certbot certificates
--dry-run runs through the entire renewal process without actually issuing a new certificate — a safe way to confirm webroot and the deploy hook are set up correctly. certbot certificates lists your certificates and their expiration dates — worth checking manually at least once after setting up the cron job.
6. Autostart after a reboot
6.1 restart: unless-stopped vs restart: always
The difference only shows up in one scenario: if the stack was stopped manually with docker compose stop. With unless-stopped, Docker remembers the container was stopped deliberately and won't bring it back automatically even after the daemon restarts. With always, the container comes back regardless, ignoring the fact that it was stopped manually — rarely the behavior you want for a production stack.
6.2 The limits of a restart policy
No restart policy helps if the stack was stopped via docker compose down: this command doesn't just stop containers, it removes them entirely. There's nothing left to restart — Docker doesn't even know such a container was supposed to exist. This is exactly the scenario that calls for an external mechanism to run docker compose up -d again.
6.3 A systemd unit for Compose
[Unit]
Description=My App (Docker Compose)
After=docker.service network-online.target
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/app
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
Type=oneshot together with RemainAfterExit=yes are the key pieces here: the docker compose up -d command exits on its own right after launching the containers in the background, and without these two settings systemd would consider the service finished and "inactive" right after startup. After=docker.service network-online.target guarantees the unit only starts once the Docker daemon and the network are already ready — otherwise docker compose up -d would fail right after boot, before Docker has had time to come up.
6.4 Enabling and verifying
sudo systemctl enable app.service
sudo systemctl start app.service
journalctl -u app.service
The most reliable way to confirm this actually works is a real test: sudo reboot, wait, log back in, and check docker compose ps. It's not worth trusting the configuration on faith without testing it through an actual reboot at least once.
6.5 Two layers working together
restart: unless-stopped in the compose file and the systemd unit solve different problems and work together, not as substitutes for each other: systemd is responsible for bringing the whole stack back up after the host reboots, while restart: unless-stopped is responsible for a single container that crashed inside an already-running stack coming back on its own, without restarting everything else.
7. Resource limits
7.1 Why this matters
Without explicit limits, a memory leak in one service, or a process that spirals out of control, can consume all the server's memory and take down the whole VPS — this typically happens at night, when no one's watching the dashboards. Limits contain the damage to one specific container: it goes down, not the neighboring services and not the host.
7.2 Syntax in Compose v2
Without Swarm, limits are set via deploy.resources.limits right inside a standard docker-compose.yml:
services:
app:
deploy:
resources:
limits:
memory: 384m
cpus: "0.5"
7.3 Different behavior when exceeded
These two limits behave differently. Exceeding limits.memory results in a SIGKILL — the kernel kills the process inside the container (the OOM killer), and if restart: unless-stopped is set, Docker brings the container back up. Exceeding limits.cpus doesn't kill anything — the service simply gets throttled, receiving less CPU time, and keeps running, just more slowly.
7.4 A rough allocation for a 2 GB RAM VPS
| Service | Memory limit |
|---|---|
| nginx | 64m |
| app | 384m |
| db | 512m |
| cache | 128m |
This is a starting point, not a universal rule — the actual numbers depend on your load. The reservations section next to limits sets not a hard ceiling but a guaranteed minimum, and it's more useful as documentation of expected consumption than as a protection mechanism.
7.5 Real-time monitoring
docker stats
Shows live CPU, memory, and network usage for every container — the first tool to reach for when picking realistic limit values instead of guessing numbers out of thin air.
8. Log rotation
8.1 The default problem
The json-file logging driver Docker uses by default has no built-in limit — the log file grows indefinitely until the disk runs out of space. Check the current state:
du -sh /var/lib/docker/containers/*/
8.2 Limiting it at the service level
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
This caps each log file at 10 megabytes and keeps at most three files in rotation — older ones get deleted automatically. The same result can be applied globally to every container on the host through /etc/docker/daemon.json, if you'd rather not write this into every service separately.
8.3 What this looks like when viewing logs
docker compose logs -f behaves the same regardless of whether rotation is configured: the command reads the currently active log files transparently to the user; rotation only affects how much history is physically kept on disk.
9. Traefik as an alternative to Nginx
9.1 The key difference in approach
Instead of a separate configuration file, Traefik reads its settings directly from labels on the services themselves in docker-compose.yml. An ACME client for Let's Encrypt is built in from the start — no separate Certbot needed.
9.2 When Traefik wins
The advantage becomes noticeable when there are many microservices that get added or removed frequently: editing labels on a specific service is simpler than editing a shared nginx.conf every time and remembering to reload it.
9.3 The trade-off
The convenience comes at the cost of access: Traefik needs /var/run/docker.sock mounted inside its own container in order to see other services' labels — and that's effectively root access to the host, by the same logic as membership in the docker group from the first article in this series. Nginx needs no such access at all.
10. Conclusion
The stack has come a long way, from a port 3000 exposed directly to the internet, to a full production configuration: Nginx in front of the app, an SSL certificate from Let's Encrypt with automatic renewal, a systemd unit for autostart after a reboot, resource limits, and log rotation.
The architecture now looks like this: the internet only reaches ports 80 and 443, where Nginx meets it; it proxies requests to app:3000 inside the app-net network; db and cache are completely unreachable from outside.
A short checklist before considering the stack ready:
- The domain's DNS points to the server's IP (
dig +short) - Certbot has issued a certificate, and HTTPS opens in the browser with no warnings
- The deploy hook calls
nginx -s reload, confirmed viacertbot renew --dry-run - The systemd unit is enabled (
systemctl enable) - The stack genuinely comes back up on its own after an actual
reboot, not just in theory - Memory limits are set and checked against real consumption via
docker stats
The stack is ready for production load. One manual step remains — deploying a new version of the app still means SSHing into the server and running commands by hand. The next article closes exactly this gap: automatic deployment through GitHub Actions or GitLab CI, which after every merge builds the image itself, pushes it to a registry, and updates the stack on the server.
📚 Series navigation:
You're reading part 3 of 5, "Docker in Production."
Previous: Part 2. Docker Compose: multiple services together ←
Next: Part 4. Docker + CI/CD: auto-deploy with GitHub Actions / GitLab CI →
🚀 A production server for your Docker stack
Reverse proxy, SSL termination, several containers under load, and limits that only make sense once you actually have enough resources to spare. Hostiserver gives you predictable infrastructure sized exactly for this scenario.
🖥️ Dedicated Servers
- From $90/mo, full control over the hardware for production workloads
- No resource neighbors: container limits are calculated against real capacity, not shared capacity
- 24/7 support: we'll help with the systemd unit, Nginx, and certificate renewal
💻 Cloud (VPS) Hosting
- From $19.95/mo, KVM isolation, dedicated vCPU and RAM
- A dedicated IP address: DNS works correctly and Let's Encrypt issuance goes smoothly, with no surprises
- NVMe disks: logs, certificates, and database data with no drop in performance
- Easy to scale: more RAM for new limits as the stack grows
💬 Not sure which option you need?
💬 Reach out — we'll help you figure it out!
Frequently Asked Questions
- Why is Certbot installed on the host instead of run in a container?
It's simpler for ongoing automatic renewal: there's no need to route extra volumes or sockets between a Certbot container and Nginx, and a cron job on the host calls the system command directly and immediately reloads Nginx via
docker compose exec. A containerized Certbot is also possible, but it adds moving parts with no real benefit for a single server.
- restart: always vs unless-stopped — what's the practical difference?
The difference only shows up if the stack was stopped manually via
docker compose stop. Withalways, Docker brings the container back even if it was stopped deliberately for maintenance. Withunless-stopped, it won't come back until you start it yourself. For a production stack,unless-stoppedis generally the safer choice.
- We ran docker compose down and the stack didn't come back up on its own — is that a restart policy bug?
No, that's expected behavior.
docker compose downremoves the containers entirely rather than just stopping them — a restart policy applies to existing containers, and here there's simply nothing left to restart. This is exactly the scenario a systemd unit is for: running a fulldocker compose up -dagain after the host reboots.
- Nginx or Traefik for a small self-hosted project?
For one or two applications on a single server, Nginx is simpler and doesn't need access to the Docker socket. Traefik starts winning once there are many services that change frequently — configuration through labels then saves time compared to manually editing
nginx.confevery time something changes.
- How do I check that a memory limit is actually working?
docker statsshows current consumption in real time — if a container is getting close to its setlimits.memory, you can spot that before the SIGKILL actually happens. The limit having kicked in shows up indocker compose psordocker inspect: the container will restart with an exit code characteristic of an OOM kill.
- Can an SSL certificate be renewed with no downtime?
Yes, that's the standard scenario.
certbot renewupdates the certificate files on disk without stopping Nginx, and the deploy hook callsnginx -s reload, which re-reads the configuration and certificates while keeping already-open connections alive. Downtime only happens if you choose standalone mode for getting the very first certificate, when Nginx has to be stopped for a few seconds.
- The stack didn't come up automatically after a reboot — where do I start diagnosing?
Start with
systemctl status app.serviceandjournalctl -u app.service— they'll show whether the unit even ran, and at what step it failed. Common causes: the unit was never enabled viasystemctl enable, or it started before Docker's own daemon due to a missing or incorrectAfter=in the[Unit]section.