Repository navigation
Guide SSL Setup
ɳSelf manages TLS certificates through nginx. Three certificate modes are supported.
ɳ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 expiryBrowsers will show a security warning for self-signed certs, expected behaviour for local dev.
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-cronSupported 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.
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.
- The served nginx container mounts the whole
ssl/directory at/etc/nginx/ssl. Mounts of single certificates orcertificates/<dir>are refused, because the install below uses symlinks that must resolve inside the container. -
ageonPATHand 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, elseACME_EMAIL, elseADMIN_EMAIL. - A DNS credential in the store under its name:
SSL_DNS_CLOUDFLARE_API_TOKEN(Cloudflare, Zone:DNS:Edit token),SSL_DNS_DIGITALOCEAN_TOKEN, orSSL_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 nothingProduction 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-digitaloceanlineage keeps its provider. - A
standalone,webrootornginxlineage 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, oraws_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 servedssl/directory (withNGINX_FRONTED_BY, the fronting project's) or the project's ownssl/. It checks both the tree you adopt from and/etc/letsencrypt: any*_hook/*-hookline inrenewal/*.conforcli.ini(certbot saves the hook acertbot renew --deploy-hookrun was given, and any certbot scheduler, such as the distrocertbot.timer, replays it), and the scripts certbot runs fromrenewal-hooks/pre,deployandpost(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 aftercd, 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 certbotssl setup --install-cron) withsystemctl 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-runonly says it would. It refuses (E151, nothing written) if the timer cannot be disabled, naming the step to take (also whensystemctlis not onPATHbut the unit file exists), and whilenself-ssl-renew.serviceis in the middle of a renewal (re-run when it finishes). If adoption fails after the disable, the message says so: re-run, orsudo systemctl enable --now nself-ssl-renew.timerto go back to certbot. Add--install-cronto install the ACME renewal timer in the same run; without it the command says that no renewal is scheduled.
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.
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).
--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.
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-configone-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/dynamicand/staticis writable). The renderer points eachcertFileat the lineage's new.<dir>.gen-<n>/directory, sodynamic.ymlparses 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) sodynamic.ymlnames the restored pair; if that second render also fails the error saysdynamic.ymlmay still name the failed generation (E151, "previous certificates restored"). Generation directories are made traversable for others (mode0711, key files stay0600) 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 toNSELF_HOST_UID:NSELF_HOST_GIDfrom.nself/compose.env, the user Traefik runs as, so Traefik can open the key. The verify probes dialNSELF_HTTPS_PORT(default 443) andNSELF_HTTP_PORT(default 80), the ports Traefik publishes, not the nginx port variables. -
Preflight. The CLI looks for the
traefikcontainer and for the wholessl/directory mounted at/nself/ssl;--nginx-containernames the traefik container. The preflight printsproxy provider: traefik. -
HTTP-01. Challenge tokens go to
ssl/.acme-webroot, which thetraefik-acmeservice 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. Createssl/.acme-webroot(owned by you) before the firstnself 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> --acmestill writes an nginxconf.dfile and reloads nginx, so it does not work under traefik yet (tracked as debt). Create the lineage before the swap, or usessl 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.
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).
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.comAfter the certificate is issued (certbot in v1.4 mode, the ACME client in v1.5 mode), the command:
- Writes
nginx/conf.d/custom-{domain}.confwith a full HTTPS server block (HTTP/2, security headers, HTTP-to-HTTPS redirect). - Tests the nginx config (
nginx -t). - 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 reloadIf you have a certificate from a commercial CA (DigiCert, Sectigo, etc.):
-
Place your files in the project's
ssl/{domain}/directory:ssl/{domain}/fullchain.pem ssl/{domain}/privkey.pem -
Write an nginx server block to
nginx/conf.d/custom-{domain}.conffollowing the same pattern thatnself ssl addgenerates. -
Test and reload nginx:
docker compose exec nginx nginx -t docker compose exec nginx nginx -s reload
| 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) |
- cmd-ssl, ssl command reference
- Guide-Production-Deployment, full server setup
- Guide-Security-Hardening, ensure TLS is properly configured
Getting Started
Commands
- Commands, Overview
- Lifecycle: cmd-init · cmd-build · cmd-start · cmd-stop · cmd-restart · cmd-dev
- Monitoring: cmd-status · cmd-logs · cmd-health · cmd-urls · cmd-doctor · cmd-monitor · cmd-alerts · cmd-sentry · cmd-watchdog
- Data: cmd-db · cmd-backup · cmd-dr · cmd-queue · cmd-webhooks
- Config: cmd-config · cmd-service · cmd-env · cmd-promote
- Networking: cmd-ssl · cmd-trust · cmd-dns-setup
- Security: cmd-access · cmd-security · cmd-secrets
- Tenancy: cmd-tenant · cmd-billing
- Plugins: cmd-plugin · cmd-license · cmd-dogfood (extracted, CLI-R11) · cmd-k8s (extracted, CLI-R11) · cmd-encryption (extracted, CLI-R11) · cmd-waf (extracted, CLI-R11) · cmd-federation (extracted, CLI-R11) · cmd-mail (extracted, CLI-R11) · cmd-dlq (extracted, CLI-R11)
- AI: cmd-ai · cmd-claw · cmd-model
- Templates: cmd-template
- Utilities: cmd-exec · cmd-clean · cmd-reset · cmd-update · cmd-upgrade · cmd-version · cmd-admin · cmd-migrate · cmd-migrate-firebase · cmd-migrate-supabase · cmd-completion
Features
- Features, Overview
- Feature-Auth
- Feature-Storage
- Feature-Search
- Feature-Functions
- Feature-Email
- Feature-Monitoring
- Feature-Plugins
- Feature-nClaw, AI Assistant
- Feature-nChat, Messaging
- Feature-nFamily, Family Social
- Feature-nCloud, Managed Hosting
- Feature-Memory-Rooms, Knowledge Organization
- Feature-Agent-Dashboard, Agent Metrics
- Feature-Image-Generation, AI Image Generation
Configuration
- Configuration, Overview
- Config-Env-Vars
- Config-Postgres
- Config-Hasura
- Config-Auth
- Config-Nginx
- Shared-Proxy
- Config-Optional-Services
- Config-Custom-Services
- Config-System
Plugins (87 + 10 monitoring)
- Plugin-Overview
- Plugin-Catalog
- Plugin-Install
- Plugin-Licensing
- Plugin-Architecture
- Plugin-Dev-Guide
- Plugin-Migrations-Contract
Free (25)
- plugin-backup
- plugin-content-acquisition
- plugin-content-progress
- plugin-cron
- plugin-donorbox
- plugin-feature-flags
- plugin-github
- plugin-github-runner
- plugin-invitations
- plugin-jobs
- plugin-link-preview
- plugin-mdns
- plugin-mlflow
- plugin-monitoring
- plugin-notifications
- plugin-notify
- plugin-paypal
- plugin-search
- plugin-shopify
- plugin-stripe
- plugin-subtitle-manager
- plugin-tokens
- plugin-torrent-manager
- plugin-vpn
- plugin-webhooks
Pro (62)
- plugin-access-controls
- plugin-activity-feed
- plugin-admin-api
- plugin-analytics
- plugin-auth
- plugin-bots
- plugin-browser
- plugin-calendar
- plugin-cdn
- plugin-chat
- plugin-claw
- plugin-claw-budget
- plugin-claw-news
- plugin-claw-web
- plugin-cloudflare
- plugin-cms
- plugin-compliance
- plugin-ddns
- plugin-devices
- plugin-documents
- plugin-entitlements
- plugin-file-processing
- plugin-game-metadata
- plugin-geocoding
- plugin-geolocation
- plugin-google
- plugin-home
- plugin-idme
- plugin-knowledge-base
- plugin-linkedin
- plugin-livekit
- plugin-media-processing
- plugin-meetings
- plugin-moderation
- plugin-mux
- plugin-object-storage
- plugin-observability
- plugin-photos
- plugin-podcast
- plugin-post
- plugin-realtime
- plugin-retro-gaming
- plugin-rom-discovery
- plugin-social
- plugin-sports
- plugin-support
- plugin-tmdb
- plugin-voice
- plugin-web3
- plugin-workflows
Planned (26)
plugin-auditplugin-blogplugin-checkoutplugin-commerceplugin-drmplugin-exportplugin-flowplugin-importplugin-ldapplugin-mailgunplugin-mediaplugin-oauth-providersplugin-pagesplugin-postmarkplugin-rate-limitplugin-reportsplugin-samlplugin-schedulerplugin-sendgridplugin-ssoplugin-subscriptionplugin-thumbplugin-transcoderplugin-twilioplugin-wafplugin-watermark
Guides
- Guide-Production-Deployment
- Guide-SSL-Setup
- Guide-Multi-Tenancy
- Guide-Security-Hardening
- Guide-Monitoring-Setup
- Guide-Backup-Restore
- Guide-Custom-Services
- Guide-Migration-from-v1
Architecture
- Architecture
- Architecture-Guardrails
- Service-Graph
- Compose-Generation
- Nginx-Generation
- Plugin-Architecture
Reference
Machine interface
-
JSON-Output,
--jsonenvelope -
Command-Registry,
nself help --json - Exit-Codes
- error-codes, Error Codes
-
Compat-V15,
NSELF_V15gate
Licensing
Security
Brand
Operations
- operations/release-cascade, Release Cascade
- operations/self-healing, Self-Healing Schema
- operations/redis-tuning, Redis Pool Tuning
- operations/meilisearch-warmup, MeiliSearch Warm-Up
- operations/jwt-rotation, JWT Key Rotation
- operations/windows-wsl2-setup, Windows / WSL2 Setup
- operations/gemini-oauth-reauth, Gemini OAuth Reauth
Contributing
Admin
- USER-ACTION-QUEUE, Pending Admin Actions
All commands (21)
- A: cmd-add · cmd-admin
- B: cmd-backup · cmd-build
- C: cmd-clean · cmd-config
- D: cmd-db · cmd-deploy · cmd-doctor
- E: cmd-exec
- I: cmd-init
- L: cmd-license · cmd-logs
- M: cmd-mcp
- R: cmd-remove · cmd-reset · cmd-restart
- S: cmd-start · cmd-status · cmd-stop
- U: cmd-update