-
Notifications
You must be signed in to change notification settings - Fork 2
HTTPS, TLS, and Certificate Management
Production HTTPS is handled entirely within the webapp container. The container runs the jonasal/nginx-certbot image, which bundles nginx with an automated Certbot daemon. On first startup, it obtains a TLS certificate from Let's Encrypt via the ACME HTTP-01 challenge and renews it automatically before it expires. nginx then terminates TLS on port 443 and proxies API traffic to the internal services.
Local development uses a plain nginx container on port 80 with no TLS — swappable by changing two lines in the Dockerfile.
- What is Let's Encrypt
- The jonasal/nginx-certbot Image
- The ACME HTTP-01 Challenge
- Certificate Files and Storage
- The Dockerfile — Production vs Local
- nginx.conf — The HTTPS Server Block
- nginx.local.conf — The Local HTTP Server Block
- DEPLOY_HOST Substitution
- docker-compose.yml — Wiring It Together
- Proxy Headers and Forwarded-For
- Full TLS Handshake Flow
Let's Encrypt is a free, automated Certificate Authority (CA) run by the Internet Security Research Group (ISRG). A CA is a trusted third party that signs TLS certificates, vouching that a given public key genuinely belongs to the domain it claims to represent. Browsers and operating systems ship with a list of trusted CAs; because Let's Encrypt is on that list, certificates it issues are trusted globally without any manual configuration.
Let's Encrypt certificates are valid for 90 days. They are renewed automatically by the Certbot daemon inside the container, typically 30 days before expiry.
The issuance protocol is ACME (Automatic Certificate Management Environment, RFC 8555). Certbot implements ACME and speaks it to Let's Encrypt's servers.
jonasal/nginx-certbot is a community Docker image that combines nginx with a supervised Certbot process. It handles the full certificate lifecycle so the application does not need to manage any of it:
- On first startup — if no certificate exists for the configured domain — it starts a temporary HTTP server, requests a certificate from Let's Encrypt via the ACME challenge, saves the certificate files, and then starts nginx with TLS enabled.
- On subsequent startups — if a valid certificate already exists — it starts nginx immediately and schedules background renewal checks.
-
Automatic renewal — a cron-like loop inside the container runs
certbot renewperiodically. When a certificate is within 30 days of expiry, Certbot renews it and reloads nginx without downtime.
The image reads one environment variable to know which email address to register with Let's Encrypt for expiry notifications:
# docker-compose.yml
webapp:
environment:
- CERTBOT_EMAIL=admin@yovi-en2a.comThe domain to certify is not read from an environment variable at runtime — it is baked into the nginx config at image build time via the DEPLOY_HOST build argument (explained below).
Let's Encrypt needs to verify that whoever is requesting the certificate actually controls the domain. The HTTP-01 challenge works as follows:
sequenceDiagram
participant LE as Let's Encrypt servers
participant W as webapp container (port 80)
LE->>W: GET /.well-known/acme-challenge/{random-token}
Note over W: Certbot serves the token from a temp file
W-->>LE: 200 OK {token}.{account-key-hash}
Note over LE: Token matches → domain verified
LE-->>W: Certificate issued
- Certbot generates a random token and places it at
/.well-known/acme-challenge/{token}inside the container. - Let's Encrypt's servers make an HTTP request to
http://{domain}/.well-known/acme-challenge/{token}from the public internet. - If the response matches, Let's Encrypt is satisfied that the server controls the domain and issues the certificate.
This is why the server must be publicly reachable on port 80 during certificate issuance. The jonasal/nginx-certbot image handles the HTTP server block for this challenge automatically — the application's nginx config does not need to define it.
Once issued, Let's Encrypt certificates are stored inside the container at:
´´´ /etc/letsencrypt/live/{domain}/ fullchain.pem ← The certificate + any intermediate CA certificates privkey.pem ← The private key (never leaves the container) /etc/letsencrypt/dhparams/ dhparam.pem ← Diffie-Hellman parameters for forward secrecy ´´´
These paths are mounted as a named Docker volume (letsencrypt) so the certificates survive container restarts and image updates:
# docker-compose.yml
webapp:
volumes:
- letsencrypt:/etc/letsencrypt
volumes:
letsencrypt:Without this volume, every container restart would trigger a fresh certificate request. Let's Encrypt enforces rate limits (5 certificates per domain per week), so losing certificates on every restart would quickly exhaust the quota.
fullchain.pem contains both the leaf certificate (for the domain) and the intermediate CA certificate (issued by ISRG Root X1). Browsers need the full chain to verify trust back to a root CA they recognize.
The webapp/Dockerfile has two modes controlled by swapping commented-out lines:
# Production image — nginx + automatic Let's Encrypt via certbot
FROM jonasal/nginx-certbot:latest
# Local image — plain nginx (no HTTPS redirect). Swap comments to use locally:
# FROM nginx:alpine
# Production: uses SSL config
COPY nginx.conf /tmp/nginx.conf.template
# Local:
# COPY nginx.local.conf /tmp/nginx.conf.template
# Production output path (jonasal/nginx-certbot):
RUN sed "s/DEPLOY_HOST_PLACEHOLDER/${DEPLOY_HOST}/g" /tmp/nginx.conf.template \
> /etc/nginx/user_conf.d/app.conf
# Local output path (nginx:alpine):
# RUN sed "s/DEPLOY_HOST_PLACEHOLDER/${DEPLOY_HOST}/g" /tmp/nginx.conf.template \
# > /etc/nginx/conf.d/default.confThe output path differs between images:
| Image | nginx config directory |
|---|---|
jonasal/nginx-certbot |
/etc/nginx/user_conf.d/ |
nginx:alpine |
/etc/nginx/conf.d/ |
jonasal/nginx-certbot reserves conf.d for its own internal HTTP-01 challenge server block and the HTTP→HTTPS redirect it manages automatically. User-provided server blocks go in user_conf.d so they are loaded after the image's own config.
nginx.conf is the production configuration. It defines only the HTTPS server block — the HTTP block (port 80) is managed entirely by the jonasal/nginx-certbot image and handles both the ACME challenge and the HTTP→HTTPS redirect automatically.
server {
listen 443 ssl;
server_name DEPLOY_HOST_PLACEHOLDER;
ssl_certificate /etc/letsencrypt/live/DEPLOY_HOST_PLACEHOLDER/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/DEPLOY_HOST_PLACEHOLDER/privkey.pem;
ssl_dhparam /etc/letsencrypt/dhparams/dhparam.pem;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://users:3000;
...
}
location /game/ {
proxy_pass http://users:3000;
...
}
location / {
try_files $uri $uri/ /index.html;
}
}| Directive | Purpose |
|---|---|
listen 443 ssl |
Accept connections on port 443 with TLS termination |
server_name |
The domain this block responds to — must match the certificate's CN/SAN |
ssl_certificate |
Path to fullchain.pem — the leaf + intermediate chain presented to clients |
ssl_certificate_key |
Path to the private key used during the TLS handshake |
ssl_dhparam |
Diffie-Hellman parameters for ephemeral key exchange, providing forward secrecy (past sessions cannot be decrypted even if the private key is later compromised) |
root |
Directory where the static Vite build output was copied |
try_files $uri $uri/ /index.html |
SPA fallback — if a file is not found, serve index.html so React Router handles the path |
Both /api/ and /game/ are forwarded to the users container (port 3000), which is the API Gateway. nginx acts as the TLS termination point — traffic between nginx and the internal containers travels over plain HTTP on Docker's private bridge network, which is acceptable because it never leaves the host machine.
location /api/ {
proxy_pass http://users:3000;
}users resolves to the API Gateway container via Docker's internal DNS. The trailing slash on proxy_pass is intentional — it strips the /api/ prefix before forwarding (e.g. /api/login is forwarded as /login to port 3000, but the API Gateway itself also handles the /api prefix, so this works with the gateway's own routing).
Used for local development. Plain HTTP on port 80, no TLS, server_name localhost. The proxy locations are identical to the production config:
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location /api/ { proxy_pass http://users:3000; ... }
location /game/ { proxy_pass http://users:3000; ... }
location / { try_files $uri $uri/ /index.html; }
}To switch to local mode, two lines in the Dockerfile are swapped: the base image (nginx:alpine instead of jonasal/nginx-certbot) and the config file (nginx.local.conf instead of nginx.conf).
DEPLOY_HOST_PLACEHOLDER appears three times in nginx.conf — once in server_name and twice in the certificate paths. It is replaced at image build time by a sed command in the Dockerfile:
ARG DEPLOY_HOST=localhost
RUN sed "s/DEPLOY_HOST_PLACEHOLDER/${DEPLOY_HOST}/g" /tmp/nginx.conf.template \
> /etc/nginx/user_conf.d/app.confDocker Compose passes the build argument from the host environment. DEPLOY_HOST comes from the host shell environment directly (set as a GitHub Secret and exported by the deployment workflow). Docker Compose passes any ARG that matches a host environment variable even without explicit mapping.
After substitution, the deployed config looks like:
server_name yovi-en2a.com;
ssl_certificate /etc/letsencrypt/live/yovi-en2a.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yovi-en2a.com/privkey.pem;This substitution happens once at build time — changing the domain requires rebuilding the image.
webapp:
build:
context: ./webapp
args:
- VITE_API_URL=http://localhost:3000
ports:
- "80:80"
- "443:443"
environment:
- CERTBOT_EMAIL=admin@yovi-en2a.com
volumes:
- letsencrypt:/etc/letsencrypt
depends_on:
- users
- game_manager
networks:
- monitor-net
volumes:
letsencrypt:| Setting | Purpose |
|---|---|
ports: 80:80 |
Exposes HTTP for the ACME challenge and HTTP→HTTPS redirect |
ports: 443:443 |
Exposes HTTPS for all application traffic |
CERTBOT_EMAIL |
Passed to Certbot for Let's Encrypt account registration and expiry alerts |
letsencrypt:/etc/letsencrypt |
Persists certificates across container restarts |
depends_on: users, game_manager |
Ensures the API Gateway and Game Manager are up before nginx starts proxying |
When nginx proxies a request to the users container, the original client IP and protocol are lost unless explicitly forwarded. All proxy locations set four headers:
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;| Header | Value | Purpose |
|---|---|---|
Host |
Original request hostname | Lets the upstream service know which domain was requested |
X-Real-IP |
Client's IP address | Used for logging and rate limiting in the API Gateway |
X-Forwarded-For |
Appends client IP to any existing chain | Standard header for tracking the full proxy hop chain |
X-Forwarded-Proto |
https |
Tells the API Gateway that the original request was over HTTPS, so it can set secure cookies and generate correct redirect URLs |
X-Forwarded-Proto is particularly important for cookie security: the API Gateway sets secure: process.env.NODE_ENV === 'production' on cookies. When behind nginx, the upstream receives plain HTTP but knows from this header that the user is on HTTPS.
sequenceDiagram
participant B as Browser
participant N as nginx (port 443)
participant U as users container (port 3000)
B->>N: TCP SYN
N-->>B: TCP SYN-ACK
B->>N: TLS ClientHello
N-->>B: TLS ServerHello
N-->>B: Certificate (fullchain.pem)
N-->>B: DH parameters (dhparam.pem)
Note over B: Verifies cert chain:<br/>leaf → Let's Encrypt R11<br/>→ ISRG Root X1 (trusted root)
B->>N: TLS Finished
N-->>B: TLS Finished
B->>N: HTTP/1.1 POST /api/login (encrypted)
N->>U: HTTP POST /api/login (plain, internal network)
U-->>N: 200 OK
N-->>B: 200 OK (encrypted)
The browser validates the certificate chain in three steps:
- The leaf certificate was signed by Let's Encrypt R11 (an intermediate CA).
- Let's Encrypt R11 was signed by ISRG Root X1 (the root CA).
- ISRG Root X1 is in the browser's trusted root store.
Because fullchain.pem contains both the leaf and the intermediate certificate, the browser can complete this chain without any additional lookups. The root CA certificate is not included in fullchain.pem — browsers already have it.