Files
ulicraft-server-v1/plan/15-letsencrypt.md
Oier Bravo Urtasun 843347ad00 feat(tls): Let's Encrypt via OVH DNS-01 + host nginx vhost
tooling/issue-letsencrypt.sh: manual script, issues one cert per name
(apex + LE_SUBDOMAINS) with acme.sh + OVH DNS-01 (no inbound ports),
installs to certs/<name>/ and reloads nginx. nginx/ulicraft.conf.tmpl:
TLS vhosts for apex/pack (static) + auth (proxy to drasl). OVH creds and
LE_EMAIL in .env.example; certs/ gitignored. plan/15-letsencrypt.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:35:07 +02:00

3.2 KiB

TLS — Let's Encrypt (OVH DNS-01) + host nginx ingress

Production serves real, publicly-trusted certs via the host's nginx. Caddy is no longer the production ingress — it moved to its own compose file for the LAN/air-gap path.

Two ingress paths

Path Ingress Compose TLS
Production host nginx docker-compose.yml + docker-compose.nginx.yml Let's Encrypt (this doc)
LAN / air-gap caddy container docker-compose.yml + docker-compose.caddy.yml (+ static/mirror) Caddy internal CA

build-stack.sh drives the caddy path (adds docker-compose.caddy.yml automatically). The nginx path is a manual deploy (below). This supersedes the ingress part of plan/14-deploy.md for production.

Certs: one per name, via OVH DNS-01

tooling/issue-letsencrypt.sh issues a separate cert for the apex and each subdomain (${BASE_DOMAIN}, auth.…, pack.…) using acme.sh + the OVH DNS-01 challenge — no inbound ports needed, so it works even on a LAN-only host.

One-time setup

  1. Install acme.sh:
    curl https://get.acme.sh | sh -s email=you@example.com
    
  2. OVH API credentials → .env (see .env.example OVH_*, LE_EMAIL, LE_SUBDOMAINS). Create a token at https://eu.api.ovh.com/createToken/ with GET/POST/PUT/DELETE on /domain/zone/*, or leave OVH_CK blank and run the script once — acme.sh prints an authorization URL; visit it, then paste the consumer key into OVH_CK and re-run.

Issue

tooling/issue-letsencrypt.sh             # real certs
LE_STAGING=1 tooling/issue-letsencrypt.sh  # dry run against the LE staging CA

Certs install to certs/<name>/{cert.pem,key.pem} (gitignored). acme.sh adds a cron for renewal; --reloadcmd reloads nginx automatically on renew. Override the reload with RELOAD_CMD=... if nginx isn't systemd-managed.

nginx

  1. Stack up with the nginx override (publishes drasl on 127.0.0.1:25585):
    docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
    
  2. Render + install the vhost (nginx/ulicraft.conf.tmpl):
    APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \
      envsubst '$APP_DIR $BASE_DOMAIN' \
      < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
    sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/
    sudo nginx -t && sudo systemctl reload nginx
    
    nginx then: apex + pack.* as static files (www, pack, custom), auth.* → drasl. Ensure www-data can read $APP_DIR/{www,pack,custom,certs}.

DNS

Point the public/party DNS for ${BASE_DOMAIN}, auth.${BASE_DOMAIN}, pack.${BASE_DOMAIN} at the host. The Minecraft container reaches them via extra_hosts: host-gateway (set in docker-compose.nginx.yml) and uses HTTPS — the JVM trusts Let's Encrypt with no CA import (the big win over the internal CA).

Why DNS-01 (not HTTP-01)

HOST_LAN_IP shows a LAN host; HTTP-01/TLS-ALPN-01 need public inbound on 80/443. DNS-01 only needs the OVH API, so it issues regardless of public reachability — and ulicraft.net is a real OVH-managed domain, which LE requires (it never issues for .lan/.local).