TLS and reverse-proxy front door
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.
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 |
|---|---|
| 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:
- Static files via the proxy itself
- 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 servicesTheory 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.invalidExpect 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 filesDocument 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/ | headFrom 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 .#labExercise 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 -datesExercise 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 -40Exercise 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 || trueExercise 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.mdupdated- 502 break/fix once
- 8080 not world-open
- Cert dates inspected once
Journal (optional)
Write three personal gotchas before continuing.