TLS and reverse-proxy front door

Updated

July 30, 2026

TLS and reverse-proxy front door

Goal: Put a TLS reverse proxy in front of a simple backend using ACME (Let’s Encrypt or lab alternative)—pick Caddy or Nginx, not both, as the house reverse-proxy pattern.

Note

Public ACME needs a public DNS name pointing at your lab. If you cannot, use internal TLS, mkcert, or Caddy/Nginx self-signed lab modes—and document the deviation. The pattern still counts.

Why this chapter exists

Services should not all expose their own TLS stacks. The production pattern:

Internet/LAN clients
    → :443 reverse proxy (TLS terminates)
        → 127.0.0.1:app-port (HTTP plain on localhost)

One certificate story, one cipher/policy place, one set of firewall holes (80/443).


Theory 1 — Choose one proxy

Proxy Strengths Notes
Caddy Automatic HTTPS UX, simple config Great default for labs
Nginx Ubiquitous, explicit ACME via security.acme + nginx module

Pick one for the rest of this part. Write the choice in docs/proxy.md.

Do not run both on 443.


Theory 2 — ACME on NixOS

NixOS security.acme (used heavily with nginx) or Caddy’s built-in ACME:

Concern Practice
Email Real email for Let’s Encrypt notices
Domains You control DNS
HTTP-01 Port 80 reachable from internet
DNS-01 Provider token via sops (not plaintext)
Staging Use LE staging while debugging rate limits

Secrets

ACME account keys are managed on disk by the tooling—do not invent plaintext private keys in the flake. DNS provider tokens: sops-nix.


Theory 3 — Caddy pattern (NixOS)

# modules/services/proxy-caddy.nix
{ pkgs, ... }:
{
  networking.firewall.allowedTCPPorts = [ 80 443 ];

  services.caddy = {
    enable = true;
    virtualHosts."app.example.invalid".extraConfig = ''
      encode gzip
      reverse_proxy 127.0.0.1:8080
    '';
  };
}

Caddy often obtains certs automatically when DNS is real. For invalid/lab domains, configure accordingly or use a local-only site block for HTTP lab testing.

Static site alternative:

services.caddy.virtualHosts."static.example.invalid".extraConfig = ''
  root * ${pkgs.writeTextDir "index.html" "<h1>lab</h1>"}
  file_server
'';

(Adjust to current Caddy module options on 26.05 if attribute names differ—verify with option search.)


Theory 4 — Nginx + ACME pattern

# modules/services/proxy-nginx.nix
{
  networking.firewall.allowedTCPPorts = [ 80 443 ];

  security.acme = {
    acceptTerms = true;
    defaults.email = "you@example.invalid";
  };

  services.nginx = {
    enable = true;
    recommendedProxySettings = true;
    recommendedTlsSettings = true;
    virtualHosts."app.example.invalid" = {
      enableACME = true;
      forceSSL = true;
      locations."/" = {
        proxyPass = "http://127.0.0.1:8080";
        proxyWebsockets = true;
      };
    };
  };
}

Theory 5 — Backend for the lab

Minimal backend options:

  1. Static files via the proxy itself
  2. Tiny declarative service:
systemd.services.lab-backend = {
  wantedBy = [ "multi-user.target" ];
  serviceConfig = {
    DynamicUser = true;
    ExecStart = "${pkgs.python3}/bin/python3 -m http.server 8080 --bind 127.0.0.1";
    WorkingDirectory = "${pkgs.writeTextDir "index.html" "<h1>backend</h1>"}";
  };
};

Bind 127.0.0.1 so the firewall need not open 8080.


Theory 6 — Failure modes

Symptom Checks
ACME fail DNS A/AAAA, port 80, rate limits, email/terms
502 bad gateway Backend down; wrong port
Cert works, app not proxyPass URL
Works on host, not remote Firewall 80/443; cloud SG
curl -vI https://app.example.invalid
journalctl -u caddy -b --no-pager | tail -50
# or nginx / acme services

Theory 7 — Certificate storage and renewal

ACME clients store account/certs under state dirs (often under /var/lib). That state is not in the Nix store.

Event Result
System generation rollback Software/config may roll back; certs usually remain
Wipe /var Certificates gone; re-issue (rate limits!)
Domain change New cert path; update proxy vhost

Back up ACME state on anything beyond a disposable lab, or accept re-issuance cost.


Theory 8 — HTTP → HTTPS redirect

curl -I http://app.example.invalid
curl -I https://app.example.invalid

Expect redirect or secure connection—not a permanent plain HTTP production path.


Theory 9 — Internal TLS without public ACME

# Conceptual lab path — exact options differ by proxy:
# - generate mkcert/local CA on admin machine
# - place cert/key via sops-nix into /run/secrets
# - point proxy ssl_certificate paths at those files

Document production delta: “In prod we would use ACME DNS-01 / HTTP-01; lab uses internal CA.”


Worked example — ports.md dual-write

Port Service
80 ACME HTTP-01 / redirect
443 HTTPS proxy
8080 localhost backend only

docs/proxy.md and docs/ports.md must agree.


Exercises

Exercise 1 — Pick proxy and document

Write docs/proxy.md with Caddy or Nginx and domain plan (public / LAN / self-signed).

Exercise 2 — Localhost backend

Add declarative backend on 127.0.0.1:8080. Prove:

curl -sS http://127.0.0.1:8080/ | head

From another machine, connection should fail if not firewalled open and not public-bound.

Exercise 3 — Enable proxy module

Import proxy module; open 80/443; rebuild.

sudo nixos-rebuild switch --flake .#lab

Exercise 4 — TLS path

If public DNS: complete ACME; curl -I https://your.domain.

If not: configure self-signed or internal CA approach; curl -k for lab proof; document how production would differ.

Exercise 5 — Headers and hygiene (light)

Enable recommended TLS/proxy settings (nginx flags above, or Caddy defaults). Confirm you do not open backend port publicly.

Exercise 6 — Break/fix

Stop backend; observe 502; start backend; recover. Journal the unit names.

Exercise 7 — ports.md + proxy.md dual-write

Ensure both docs agree on engine, public ports, backend, TLS mode.

Exercise 8 — Certificate expiry awareness

echo | openssl s_client -connect 127.0.0.1:443 -servername app.example.invalid 2>/dev/null | openssl x509 -noout -dates

Exercise 9 — ss audit

ss -tlnp | grep -E ':80|:443|:8080'

Confirm 8080 is 127.0.0.1 only.

Exercise 10 — ACME journal

journalctl -u acme-app.example.invalid.service -b --no-pager 2>/dev/null | tail -40 || true
journalctl -u caddy -b --no-pager | tail -40

Exercise 11 — Staging note

If you hit LE rate limits, document staging usage for next attempt.

Exercise 12 — WebSocket flag (if needed)

If your app needs websockets, confirm proxy config (proxyWebsockets or Caddy defaults).

Exercise 13 — Single proxy enforcement

# ensure only one of caddy/nginx enabled
systemctl is-active caddy nginx 2>/dev/null || true

Exercise 14 — Secret path for DNS-01 (plan)

If using DNS-01, name the sops secret key in docs (no value).

Exercise 15 — Smoke from remote

From another machine (or phone on data): hit https://… if public. Record result.


Common gotchas

Symptom / mistake What to do
LE rate limited Use staging; wait; fix DNS first
Both Caddy and Nginx on 443 Pick one
Backend on 0.0.0.0:8080 Bind localhost
Secrets for DNS-01 in git sops
IPv6 DNS mismatch Align AAAA or disable
Forgot port 80 for HTTP-01 Open 80 or use DNS-01
502 ignored Fix backend unit first
Certs wiped with /var Backup ACME state
docs disagree Dual-write exercise

Checkpoint

  • One proxy chosen and documented
  • Backend on localhost
  • 80/443 in firewall intentionally
  • HTTPS or documented lab TLS alternative works
  • docs/ports.md updated
  • 502 break/fix once
  • 8080 not world-open
  • Cert dates inspected once

Journal (optional)

Write three personal gotchas before continuing.