Skip to content

HTTPS, TLS, and Certificate Management

Matías Valle Trapiella edited this page Apr 13, 2026 · 1 revision

HTTPS, TLS, and Certificate Management

Overview

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.


Table of Contents


What is Let's Encrypt

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.


The jonasal/nginx-certbot Image

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:

  1. 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.
  2. On subsequent startups — if a valid certificate already exists — it starts nginx immediately and schedules background renewal checks.
  3. Automatic renewal — a cron-like loop inside the container runs certbot renew periodically. 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.com

The 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).


The ACME HTTP-01 Challenge

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
Loading
  1. Certbot generates a random token and places it at /.well-known/acme-challenge/{token} inside the container.
  2. Let's Encrypt's servers make an HTTP request to http://{domain}/.well-known/acme-challenge/{token} from the public internet.
  3. 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.


Certificate Files and Storage

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 Dockerfile — Production vs Local

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.conf

The 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 — The HTTPS Server Block

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 by directive

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

Proxy locations

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).


nginx.local.conf — The Local HTTP Server Block

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 Substitution

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.conf

Docker 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.


docker-compose.yml — Wiring It Together

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

Proxy Headers and Forwarded-For

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.


Full TLS Handshake Flow

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)
Loading

The browser validates the certificate chain in three steps:

  1. The leaf certificate was signed by Let's Encrypt R11 (an intermediate CA).
  2. Let's Encrypt R11 was signed by ISRG Root X1 (the root CA).
  3. 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.