Skip to content

Guide SSL Setup

github-actions[bot] edited this page Oct 15, 2026 · 9 revisions

Guide: SSL / TLS Setup

ɳSelf manages TLS certificates through nginx. Three certificate modes are supported.

Self-Signed Certificates (Default)

ɳSelf auto-generates a self-signed certificate when you first run nself build. This is appropriate for local development and internal networks.

nself ssl status    # check certificate details and expiry

Browsers will show a security warning for self-signed certs, expected behaviour for local dev.

Base Domain Certificates (DNS-01)

Use nself ssl setup to provision a wildcard or multi-domain Let's Encrypt certificate for your configured BASE_DOMAIN via DNS-01 challenge. This requires your DNS provider credentials to be in place before running.

# Wildcard certificate via Cloudflare
nself ssl setup --provider cloudflare --wildcard --email admin@example.com

# Per-subdomain certificate via Route53
nself ssl setup --provider route53 --email admin@example.com

# Enable automatic renewal via systemd timer (Linux)
nself ssl setup --provider cloudflare --wildcard --install-cron

Supported providers: cloudflare, route53, digitalocean. With certbot (v1.4 mode) the credentials file is /etc/letsencrypt/{provider}.ini.

Certificates land in ssl/{domain}/ inside the project directory. nginx reads them at /etc/nginx/ssl/{domain}/ via the ./ssl:/etc/nginx/ssl:ro mount.

CLI-Owned ACME (DNS-01, no host certbot)

nself ssl setup --acme and nself ssl renew --acme issue and renew Let's Encrypt certificates from inside the stack (ADR 0026). A pinned lego container runs once per issuance; nothing is installed on the host and the Docker socket is never mounted. v1.5 default: with NSELF_V15=1, ssl setup, ssl add and ssl renew use this client without --acme, and --install-cron installs the nself-acme-renew timer. ACME failures carry E470 (issuance), E471 (install or served-certificate check; the error text says whether the previous certificate was restored, and on ssl add the new certificate stays installed) or E472 (DNS credential missing). E470 and E471 exit 2 like E151 did; E472 exits 1. Without NSELF_V15, certbot keeps working for projects that do not pass --acme, and failures stay E151. nginx no longer emits ssl_stapling or ssl_trusted_certificate: Let's Encrypt certificates carry no OCSP URL.

Prerequisites

  • The served nginx container mounts the whole ssl/ directory at /etc/nginx/ssl. Mounts of single certificates or certificates/<dir> are refused, because the install below uses symlinks that must resolve inside the container.
  • age on PATH and an age key (SECRETS_AGE_KEY_PATH, default ~/.config/nself/age-key.txt). DNS credentials live in the project secret store (.secrets/<env>.age), never in a file or on a command line.
  • An ACME contact: --email, else ACME_EMAIL, else ADMIN_EMAIL.
  • A DNS credential in the store under its name: SSL_DNS_CLOUDFLARE_API_TOKEN (Cloudflare, Zone:DNS:Edit token), SSL_DNS_DIGITALOCEAN_TOKEN, or SSL_DNS_AWS_ACCESS_KEY_ID + SSL_DNS_AWS_SECRET_ACCESS_KEY (Route53).

Always start with a dry run. It resolves and prints the served root, ssl dir, nginx dir, nginx container and its mount, and writes nothing:

nself ssl setup --acme --dry-run
nself ssl setup --acme --agree-tos                 # issue BASE_DOMAIN, api., auth. (or --wildcard)
nself ssl setup --acme --install-cron              # daily renewal timer (Linux)
nself ssl renew --acme --dry-run                   # which lineages are due
nself ssl renew --acme --staging --agree-tos       # rehearse against the staging CA; installs nothing

Adopting certbot lineages

Production boxes already hold certbot lineages. Adoption reads /etc/letsencrypt/renewal/*.conf and live/<name>/cert.pem, maps each lineage to the served directory its names are served from, and records it as dns-01. It never issues, and it never edits or removes certbot's files (renewal files, hooks, credential files). It does disable nself's certbot timer, see below.

nself ssl setup --acme --adopt-certbot --dns-credential-file ./cloudflare.ini --dry-run
nself ssl setup --acme --adopt-certbot --dns-credential-file ./cloudflare.ini
  • A dns-cloudflare / dns-route53 / dns-digitalocean lineage keeps its provider.
  • A standalone, webroot or nginx lineage is converted to dns-01 only when you pass a credential file; without one it is refused with that remediation.
  • The credential file is a certbot DNS-plugin INI with exactly one provider: dns_cloudflare_api_token, dns_digitalocean_token, or aws_access_key_id + aws_secret_access_key. A Cloudflare global key is refused: create a scoped token.
  • Served certificates are left byte for byte as they are unless a target is missing or older than certbot's live certificate.
  • Adoption is saved lineage by lineage; if it fails part way, fix the cause and run it again to finish. Names that certbot also manages are flagged by nself ssl status.
  • Adoption refuses (E151, nothing written) while a certbot hook would copy certificates into the served ssl/ directory (with NGINX_FRONTED_BY, the fronting project's) or the project's own ssl/. It checks both the tree you adopt from and /etc/letsencrypt: any *_hook / *-hook line in renewal/*.conf or cli.ini (certbot saves the hook a certbot renew --deploy-hook run was given, and any certbot scheduler, such as the distro certbot.timer, replays it), and the scripts certbot runs from renewal-hooks/pre, deploy and post (executable, no trailing ~; comment lines and trailing comments outside quotes are ignored). The check runs again right after the timer is disabled, in case a renewal saved its hook in between. The error names each file and line. Remove those lines and move those scripts aside, then run it again. A hook that only reads that directory refuses too: the check cannot tell a read from a write without running the shell, and a wrong guess would let certbot overwrite the ACME pair. A path the hook builds at run time (from variables, a relative path after cd, or another script it calls) is not detected, nor is a per-user ~/.config/letsencrypt/cli.ini. The CLI only reads certbot's files.
  • Before its first write, adoption disables nself-ssl-renew.timer (installed by the certbot ssl setup --install-cron) with systemctl disable --now (or stops it if it is only running): its deploy hook would overwrite the ACME generation outside the ACME lock. The state is read first, so a re-run changes nothing; --dry-run only says it would. It refuses (E151, nothing written) if the timer cannot be disabled, naming the step to take (also when systemctl is not on PATH but the unit file exists), and while nself-ssl-renew.service is in the middle of a renewal (re-run when it finishes). If adoption fails after the disable, the message says so: re-run, or sudo systemctl enable --now nself-ssl-renew.timer to go back to certbot. Add --install-cron to install the ACME renewal timer in the same run; without it the command says that no renewal is scheduled.

HTTP-01 for one host (nself ssl add <domain> --acme)

Use HTTP-01 when you have one public hostname, no DNS API credential and port 80 reaches nginx. It cannot issue a wildcard; use ssl setup --acme --wildcard for that.

nself ssl add app.example.org --acme --dry-run      # prints the lineage, target and webroot; writes nothing
nself ssl add app.example.org --acme --agree-tos [--upstream app:3000]

The same prerequisites apply (whole-dir ssl/ mount, a contact), except age and the age key, which only DNS credentials need. The pinned lego container writes the challenge token into ssl/.acme-webroot/, which nginx serves read only at /etc/nginx/ssl/.acme-webroot. Before it asks the CA, the CLI writes a probe token there and fetches it from 127.0.0.1:NGINX_HTTP_PORT with your host name; if nginx does not answer, nothing is issued and the error says to run nself build and restart nginx.

nginx answers the challenge from one location that nself build renders into the default server and into every route block that listens on port 80 (a route without a certificate yet), and that ssl add writes into the port-80 block of custom-<domain>.conf, always ahead of the catch-all redirect or proxy:

location ^~ /.well-known/acme-challenge/ {
    root /etc/nginx/ssl/.acme-webroot;
    disable_symlinks on from=$document_root;
    limit_except GET { deny all; }
    if ($uri !~ "^/[.]well-known/acme-challenge/[A-Za-z0-9_-]+$") { return 404; }
    try_files $uri =404;
}

Only a bare token ([A-Za-z0-9_-]+) is served: a directory, a sub-path, a dotted name, an encoded slash, a symlink below the webroot (disable_symlinks on) and any non-GET request are refused, and ^~ on the ACME prefix leaves every application path alone. SSL route blocks do not carry it. The change reaches a running box only with the next nself build; it adds the location to the existing nginx/conf.d/default.conf and the non-SSL nginx/sites/*.conf files.

nself ssl renew --acme renews an http-01 lineage the same way (probe first, then a new generation, then a served-fingerprint check); the renewal timer does too. An HTTP-01-only box needs no age, no age key and no DNS credential for ssl add --acme or ssl renew --acme.

Adopting a certbot webroot lineage: nself ssl setup --acme --adopt-certbot records it as http-01 when the served nginx answers a probe token file for every name of the lineage (no credential file needed). If the location is missing the lineage is refused with the remediation (run nself build, restart nginx, retry, or pass --dns-credential-file to convert it to dns-01). The probe leaves nothing behind.

How a certificate is installed

Each target ssl/certificates/<dir> (always certificates/<one plain name>; anything else is refused) becomes a relative symlink to .<dir>.gen-<n>/, which holds fullchain.pem, privkey.pem, cert.pem and chain.pem (0600; the same files as certbot's live/ directory, so confs that name chain.pem keep loading). A new generation is written and its key and certificate are checked against each other; then one rename of a temporary symlink over <dir> switches both files at once. nginx is then tested (nginx -t) and reloaded through docker exec, and the served certificate's fingerprint is checked. If any step fails the previous generation is restored and the command exits with E151. The previous generation stays until the next successful install. Every --acme run first repairs a missing link or a mismatched pair from the newest valid generation, so a crash between steps cannot leave a half-installed pair.

Only one mutating --acme run proceeds at a time: it holds ssl/.acme/.lock, and a second run waits up to 10 seconds, then exits with E151 naming the holder's pid.

State lives in ssl/.acme/ (0700, with a .gitignore of *): the ACME account, certificates/, staging/ and lineages.json (names, challenge, provider, credential secret names, targets; never a value).

Renewal timer

--install-cron writes nself-acme-renew.service and .timer (daily 03:30, up to 1 hour random delay, Persistent=true). The service sets SECRETS_AGE_KEY_PATH and HOME explicitly, because systemd's bare environment has neither, and runs nself trust ssl renew --acme --quiet from the project directory. The unit runs as root: run setup --acme --install-cron as the user who owns the age key (the install message prints the key path the unit will use), or HOME and the key path will point at the wrong user. Lineages with 30 days or fewer left are renewed. Before writing the units it applies the same certbot hook check and disables nself-ssl-renew.timer the same way adoption does (and refuses, installing nothing, if it cannot); issuing a new lineage with setup --acme does both before it issues.

Once ssl/.acme/lineages.json holds lineages, the v1.4 certbot nself ssl setup (without --acme) refuses: certbot would write over the ACME-managed certificates.

Certificates under traefik

When the free traefik plugin serves the stack (nself add traefik), --acme issues, installs and renews exactly as above, with these differences:

  • Reload. There is no nginx container to reload. After the generation switch, the CLI re-runs the plugin's traefik-config one-shot (docker run --rm --pull never, the service's own hardening: read-only root, no capabilities, no network, no Docker socket; the spec comes from the project's compose model and is refused if its image is not a plain reference, a mount lies outside the project and the served stack, or anything but /dynamic and /static is writable). The renderer points each certFile at the lineage's new .<dir>.gen-<n>/ directory, so dynamic.yml parses differently and Traefik's file provider reloads it without a restart. The served-certificate check that follows proves the reload; if the render or the check fails, the previous generation is restored and the render runs once more (best effort) so dynamic.yml names the restored pair; if that second render also fails the error says dynamic.yml may still name the failed generation (E151, "previous certificates restored"). Generation directories are made traversable for others (mode 0711, key files stay 0600) because the renderer runs as root without capabilities and could not otherwise stat the files inside a directory owned by you. When the renewal runs as root (the systemd timer) the new generation is also handed to NSELF_HOST_UID:NSELF_HOST_GID from .nself/compose.env, the user Traefik runs as, so Traefik can open the key. The verify probes dial NSELF_HTTPS_PORT (default 443) and NSELF_HTTP_PORT (default 80), the ports Traefik publishes, not the nginx port variables.
  • Preflight. The CLI looks for the traefik container and for the whole ssl/ directory mounted at /nself/ssl; --nginx-container names the traefik container. The preflight prints proxy provider: traefik.
  • HTTP-01. Challenge tokens go to ssl/.acme-webroot, which the traefik-acme service serves read-only; Traefik's default server answers /.well-known/acme-challenge/ for hosts no route owns, and each route carries its own challenge location. Create ssl/.acme-webroot (owned by you) before the first nself start: if Docker creates it, it is owned by root and lego cannot write the tokens.
  • No ACME inside Traefik. Certificates stay core-issued (ADR 0026 and ADR 0027); Traefik never contacts a CA.
  • nself ssl add <domain> --acme still writes an nginx conf.d file and reloads nginx, so it does not work under traefik yet (tracked as debt). Create the lineage before the swap, or use ssl setup --acme.

Linux evidence: scripts/e2e/proxy-swap.sh --renew (workflow proxy-swap-e2e, leg renew) swaps a stack to traefik and renews an HTTP-01 lineage against Pebble: the served fingerprint changes, certFile names the new generation, Traefik does not restart, and an injected render failure leaves the previous generation served.

What the CLI never touches

Host certbot files (/etc/letsencrypt, hooks, credential files; the only certbot change is disabling nself-ssl-renew.timer), nginx configuration, compose files and container definitions. It does not recreate or restart containers; only nginx -s reload (under traefik, the traefik-config one-shot).

Custom Domain Certificates (HTTP-01)

Use nself ssl add to provision a certificate for an external custom domain (e.g., a white-labelled subdomain or a partner's domain). This uses HTTP-01 challenge, no DNS provider configuration needed.

# Add certificate and proxy traffic to a backend container
nself ssl add portal.example.com --upstream portal-app:8080

# Add certificate without configuring an upstream yet
nself ssl add portal.example.com

After the certificate is issued (certbot in v1.4 mode, the ACME client in v1.5 mode), the command:

  1. Writes nginx/conf.d/custom-{domain}.conf with a full HTTPS server block (HTTP/2, security headers, HTTP-to-HTTPS redirect).
  2. Tests the nginx config (nginx -t).
  3. Reloads nginx.

The generated conf uses proxy_pass http://{upstream} when --upstream is provided, or returns a 200 response until an upstream is configured.

To update the upstream later, edit nginx/conf.d/custom-{domain}.conf and reload:

docker compose exec nginx nginx -s reload

Custom Certificate Installation

If you have a certificate from a commercial CA (DigiCert, Sectigo, etc.):

  1. Place your files in the project's ssl/{domain}/ directory:

    ssl/{domain}/fullchain.pem
    ssl/{domain}/privkey.pem
    
  2. Write an nginx server block to nginx/conf.d/custom-{domain}.conf following the same pattern that nself ssl add generates.

  3. Test and reload nginx:

    docker compose exec nginx nginx -t
    docker compose exec nginx nginx -s reload

SSL Commands

Command Description
nself ssl status Show certificate path, issuer, and expiry date; with --acme lineages, also a Lineages table and a certbot-overlap warning
nself ssl renew Trigger manual certificate renewal prompt
nself ssl setup Provision a wildcard/multi-domain cert via DNS-01
nself ssl add <domain> Provision a cert for a single custom domain via HTTP-01 (certbot)
nself ssl add <domain> --acme Same, with the CLI's own ACME client: HTTP-01 through ssl/.acme-webroot
nself ssl setup --acme Issue or adopt certbot lineages with the CLI's own ACME client (DNS-01)
nself ssl renew --acme Renew the lineages the CLI manages (30 days or fewer left, or --force)

See Also


← Home | _Sidebar

Home


Getting Started


Commands


Features


Configuration


Plugins (87 + 10 monitoring)

Free (25)
Pro (62)
Planned (26)
  • plugin-audit
  • plugin-blog
  • plugin-checkout
  • plugin-commerce
  • plugin-drm
  • plugin-export
  • plugin-flow
  • plugin-import
  • plugin-ldap
  • plugin-mailgun
  • plugin-media
  • plugin-oauth-providers
  • plugin-pages
  • plugin-postmark
  • plugin-rate-limit
  • plugin-reports
  • plugin-saml
  • plugin-scheduler
  • plugin-sendgrid
  • plugin-sso
  • plugin-subscription
  • plugin-thumb
  • plugin-transcoder
  • plugin-twilio
  • plugin-waf
  • plugin-watermark

Guides


Architecture


Reference


Machine interface


Licensing


Security


Brand


Operations


Contributing


Admin


Changelog


All commands (21)

Clone this wiki locally