Skip to content

TLS Certificates

zach115th edited this page Aug 10, 2026 · 1 revision

TLS Certificates

IRIS-NG terminates TLS at the iriswebapp_nginx container. By default it uses the self-signed certificate generated by scripts/generate_dev_certs.sh — good for a lab, and the reason your browser shows a warning on first login.

You can point it at your own certificate instead — Let's Encrypt, an internal CA, or a commercial cert — without rebuilding any image.


How it resolves

Three settings in .env combine into the path nginx opens:

Setting Meaning Default
CERT_DIR Directory on the host holding the cert and key. Bind-mounted read-only at /www/certs/. ./certificates/web_certificates
CERT_FILENAME Certificate path, relative to CERT_DIR — may include subdirectories. iris_dev_cert.pem
KEY_FILENAME Private key path, relative to CERT_DIR. iris_dev_key.pem
SERVER_NAME Hostname nginx serves. Must match the certificate. iris.app.dev

nginx always reads /www/certs/<CERT_FILENAME>. Only the host side of the mount moves.

CERT_DIR is read by Docker Compose, not by nginx. Do not add it to the nginx environment: list or to the envsubst allowlist in docker/nginx/entrypoint.sh — it is not a template placeholder.


Default: self-signed

Nothing to configure. If the files are missing:

bash scripts/generate_dev_certs.sh
docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps nginx

Using your own certificate

Set the three values in .env and recreate nginx. No rebuild is needed.

CERT_DIR=/etc/ssl/iris-ng
CERT_FILENAME=iris.example.com.crt
KEY_FILENAME=iris.example.com.key
SERVER_NAME=iris.example.com
docker compose up -d --force-recreate --no-deps nginx

CERT_FILENAME should point at the full chain (server certificate followed by any intermediates). A server certificate alone will validate in a browser that already holds the intermediate cached, and fail for everyone else and for API clients — an intermittent failure that is unpleasant to diagnose.


Let's Encrypt

Point CERT_DIR at the whole /etc/letsencrypt tree, not at live/<domain>:

CERT_DIR=/etc/letsencrypt
CERT_FILENAME=live/iris.example.com/fullchain.pem
KEY_FILENAME=live/iris.example.com/privkey.pem
SERVER_NAME=iris.example.com

Why the whole tree

The files under live/<domain>/ are symlinks into ../../archive/<domain>/. If you mount only live/<domain>, the symlink targets fall outside the mount and are dangling inside the container — nginx cannot read the certificate even though ls shows the file present on the host.

nginx will tell you so on startup rather than failing obscurely:

FATAL: certificate is a symlink whose target does not exist inside the container:
       /www/certs/live/iris.example.com/fullchain.pem

Permissions

nginx runs inside the container as www-data (uid 33). certbot creates /etc/letsencrypt/archive/ as 0700 root:root, so the private key is unreadable by default and nginx exits with:

FATAL: private key exists but is not readable by www-data

Grant traversal and read access on the host. Least-privilege option, using an ACL for uid 33 only:

sudo setfacl -m u:33:rx /etc/letsencrypt/live /etc/letsencrypt/archive
sudo setfacl -m u:33:r  /etc/letsencrypt/archive/iris.example.com/privkey*.pem

The blunter alternative is chmod 0755 on those two directories, which exposes every private key on the host to every local user. Prefer the ACL.

Re-apply after certbot creates a new archive file, or use the deploy hook below to do both in one place.

Renewal does not reload nginx

certbot repoints the symlink; nginx keeps serving the certificate it already has open. Your site will silently continue presenting the expired certificate until nginx is reloaded. Add a deploy hook:

sudo certbot renew \
  --deploy-hook 'setfacl -m u:33:r /etc/letsencrypt/archive/iris.example.com/privkey*.pem && docker compose -f /path/to/iris-ng/docker-compose.yml exec -T nginx nginx -s reload'

A container restart works too, and costs a few seconds of downtime:

docker compose restart nginx

Verifying

# What nginx is actually presenting
echo | openssl s_client -connect localhost:443 -servername iris.example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# Chain completeness — "Verify return code: 0 (ok)" means the chain is complete
echo | openssl s_client -connect localhost:443 -servername iris.example.com 2>&1 | grep "Verify return code"

Check the mount resolved to the directory you meant:

docker inspect iriswebapp_nginx --format '{{range .Mounts}}{{if eq .Destination "/www/certs"}}{{.Source}}{{end}}{{end}}'

Troubleshooting

nginx validates the certificate and key before starting and reports the resolved path alongside the variable that produced it. Read docker logs iriswebapp_nginx first — the message names the cause.

Message Cause
certificate not found CERT_DIR points at the wrong directory, or CERT_FILENAME is wrong. The log prints both.
symlink whose target does not exist inside the container Let's Encrypt live/ mounted without archive/. Point CERT_DIR at /etc/letsencrypt.
not readable by www-data Host permissions. See Permissions.
CERT_FILENAME is not set Variable missing from .env, or .env not being read (check you are running compose from the repo root).
Browser warns despite a valid certificate SERVER_NAME does not match the certificate's CN/SAN.
Certificate is valid but clients intermittently fail CERT_FILENAME points at the server certificate rather than the full chain.

The container has restart: always, so a misconfiguration produces a crash loop and the message repeats — that is expected, and the log is still the place to look.


Kubernetes

The Helm chart does not use these variables. TLS is terminated at the Ingress, via the standard tls.secretName field — see Kubernetes.

Clone this wiki locally