Skip to content

TLS Certificates

Administration > Operations > Certificates (/v2/admin/operations) shows whether this installation really runs over HTTPS — and what to do when it does not.

This page was rewritten from scratch in September 2026

The earlier version described an upload screen for certificates, a warning 30 days before expiry, a certbot container, audit logging and an operation admin_settings. None of that existed. What did exist was an endpoint without a screen that accepted database rows — a typed-in expiry date looked exactly like a finding.


What calServer does here, and what it does not

calServer does not issue certificates. That is the job of the ACME service (letsencrypt-nginx-proxy, image nginxproxy/acme-companion) next to the reverse proxy. It requests them from Let's Encrypt, drops them into the certificate directory and renews them 30 days before expiry on its own.

calServer reads that directory and compares it against the domains that are supposed to be served. The API container has neither access to the Docker socket nor write access to the .env or to the certificate directory. Granting either would make more privilege reachable through the screen than the role carries.

The one exception is a certificate you bring yourself. Even there the application does not write: it validates and hands over to the updater service, the only container with a Docker socket. Issuance and renewal stay with the ACME service in every case.

The outage nobody notices otherwise

When a domain has no certificate file, the reverse proxy builds no HTTPS block at all for that name. The installation keeps answering over HTTP, every container stays green, and no log says anything. That is precisely the case this page makes visible.

Permission

Reading: basic_setting_admin — the same operation as the File storage tab. Without it the tab does not appear.

Installing: certificate_edit (Install certificates). Deliberately separate: someone allowed to maintain storage paths should not thereby be able to replace the installation's private TLS key. Without this operation the Own certificate button is missing, but the page stays readable.

The banner at the top

One sentence answering the question that matters.

Message Meaning
HTTPS covers every domain Every configured domain carries a valid, trusted certificate. The next expiry is named alongside.
A certificate expires soon Less than 21 days left. The ACME service renews 30 days ahead — so by now that has failed repeatedly.
A domain is served without a certificate One vhost has no matching file. That name runs over HTTP.
The served certificate is one no browser trusts It comes from the Let's Encrypt staging environment or is self-signed. To visitors this looks like an attack.
A certificate has expired The browser aborts before the login screen appears.
No domain is known CALSERVER_HOST is empty. Without that variable the reverse proxy builds no server block at all.
This installation manages no certificates LETSENCRYPT is not true and no file is present. If TLS terminates upstream (load balancer, ingress), that is correct.
The certificate state cannot be determined The certificate directory is not visible to the API container. On Kubernetes that is the normal case, see below.

"Cannot be determined" and "no certificates" are deliberately two different messages. Turning a missing directory into "unprotected" would be constant false alarm on every Kubernetes installation.

No banner at all means the request failed

The page then shows the reason together with the HTTP status and a retry button. That distinction matters: the status endpoint deliberately answers 200 even when it sees no certificate directory at all. A failed request therefore never means "no certificates", it always means something else — 403 missing permission, 404 an API container older than the interface, 5xx a reason in the application log, no status a request that never arrived.

Per domain

For every configured domain the page shows which file the reverse proxy would pick up and whether it is any good.

State Meaning
Valid Fits, covers this name, does not expire soon.
Expiring Less than 21 days left.
Expired The browser refuses.
Not trusted Staging or self-signed.
No certificate No file for this vhost — this name runs over HTTP.
Wrong name A file is present but does not cover this name. The reverse proxy selects purely by file name and serves it anyway; the browser refuses.
Unknown The directory is unreadable, nothing can be said.

Alongside: issuer, expiry date with remaining days, the names the certificate actually covers (the SAN list) and the SHA-256 fingerprint.

default.crt does not count

The reverse proxy generates a self-signed default.crt for itself at startup. It belongs to no domain — for a named vhost without a file the proxy builds no TLS block at all instead of serving that certificate. This page therefore does not count it as a certificate in place, so the number in the footer can be lower than an ls in the directory.

File name and coverage are two different things

The reverse proxy picks the file by its name alone: for app.example.com it looks for app.example.com.crt, then example.com.crt, then one level up. Whether the certificate it found actually carries the name it does not check — the browser does. Hence Wrong name as a state separate from No certificate.

When a domain has no certificate

For every gap the page names the environment variable it hangs on and the deploy command to copy. It deliberately executes nothing.

Domain Variable Container that gets recreated
Main application CALSERVER_HOST calserver (side-by-side) or calserver-frontend (V2 alone)
Database tool ADMINER_HOST adminer
MCP server MCP_HOST calserver-mcp (also activates profile v2-mcp)

The sequence:

  1. Set the variable in the .env of the Docker directory.
  2. Verify that DNS points the name at this server and that port 80 is reachable from outside. Without port 80 Let's Encrypt cannot validate the name, and no certificate is issued.
  3. Redeploy — only then does the container receive the changed environment:
cd /var/calserver && ./deploy.sh

Changing the .env alone does nothing: the reverse proxy reads the vhost from the environment of the running container.

There is no field a command is typed into

Same rule as for scheduled tasks: a field whose content the server executes would be a way to obtain more privilege through the screen than the role carries.

The staging trap

LETSENCRYPT_TEST switches to the Let's Encrypt staging environment. The switch does not evaluate the value — it only checks whether anything is in it at all:

ACME_CA_URI=${LETSENCRYPT_TEST:+https://acme-staging-v02.api.letsencrypt.org/directory}

So LETSENCRYPT_TEST=false turns staging on. To turn it off the variable must be emptied or removed. Certificates from there carry (STAGING) in the issuer and trigger a browser warning page. The screen says so explicitly whenever it applies.

The table below (mirror)

Sync reads the certificate directory and updates the ssl_certificate table. It is a mirror, not a management screen — same pattern as the seal credentials:

  • The certificate wins for everything it carries: subject, issuer, serial, fingerprint, validity, self-signing. These fields are overwritten on every sync.
  • The database wins for display name and note. No sync touches them; they are only pre-filled when an entry first appears.
  • Vanished entries are not deleted. The row keeps its last-seen timestamp and is reported as no longer served. A mount that is briefly missing at startup would otherwise look like data loss.

The sync is an explicit button and does not happen as a side effect of loading: the display above reads live from the directory anyway.

If the table itself cannot be read — on a partially migrated installation it is missing, or its last_seen_at column is — the footer says so and quotes the database message. The state above is unaffected: it comes from the directory, not from the database. "Never synced" and "table not readable" are two different facts, and the footer keeps them apart.

Installing your own certificate

Not every installation goes through Let's Encrypt: a purchased certificate, one from your own corporate CA, a group-wide wildcard. Every domain therefore carries an Own certificate button. It requires the Install certificates (certificate_edit) permission — separate from the basic settings, because replacing the private key is a different order of magnitude from reading this page.

First: take the name away from the ACME service

This is the step you must not skip. As long as the ACME service owns a name, it keeps issuing and replaces the file on its next run. Your own certificate does land in the directory immediately and looks entirely correct until then — and is gone afterwards.

Every domain therefore carries a marker: Automatic or Self-managed. If it still says "Automatic", the .env needs:

# SET the variable and LEAVE IT EMPTY. A value would mean a different name,
# an absent variable would mean "as before".
ACME_APP_HOST=
Variable Domain
ACME_APP_HOST main application (CALSERVER_HOST)
ACME_ADMINER_HOST database tool (ADMINER_HOST)
ACME_MCP_HOST MCP server (MCP_HOST)

Then redeploy — changing the .env alone does nothing, a container reads its environment only when it is recreated:

cd /var/calserver && ./deploy.sh

Important: leaving the variable empty disables issuance only. The vhost itself remains; the installation stays reachable under the same name.

Then upload

The dialog takes three fields, each either as a file or as pasted text:

Field Content
Certificate The PEM block. If the file already contains the chain (fullchain), this field is enough.
Intermediate certificates The chain up to the root. Without it some clients reject the certificate even when browsers accept it.
Private key Unencrypted. A passphrase-protected key must be decrypted first — the reverse proxy cannot prompt for one.

Validation happens before anything is written, twice and independently:

  • Do certificate and key belong together?
  • Does the certificate cover this name? (Wildcards included.)
  • Is it valid — neither expired nor only effective later?

A "no" to any of these takes the vhost's HTTPS server block away, and the installation would keep running over HTTP while all containers stay green. So nothing that fails even one of them is written.

Where the key ends up

It is passed through and stored nowhere: not in the database, not in a backup, not in the log. It is written solely as a file in the certificate directory, with 0600 permissions. What gets logged is what stands in the certificate: name, issuer, fingerprint, validity.

Neither the application nor the API container may write there: that is the job of the updater service (calserver-updater), the only container with a Docker socket. If it does not run on this installation, the dialog says so explicitly — set it up via UPDATER_TOKEN in the .env and the v2-updater compose profile.

Without Let's Encrypt at all

An installation that always brings its own certificates leaves LETSENCRYPT at false, so the ACME service does not run at all. The reverse proxy still gets its certificate directory: since August 2026 the mount is part of the base file set instead of only the HTTPS overlay. Before that this path did not exist — without LETSENCRYPT=true nginx-proxy had no directory, built no TLS server block, and silently served plain HTTP.

Two ways to get the files there:

Way When
"Own certificate" dialog (above) The normal case. Needs the updater service, validates the material up front and stores it under the name the reverse proxy looks for.
Host directory via TLS_CERTIFICATE_HOST_PATH When the certificates are already maintained on the host (corporate CA, an existing rollout process, certificates from another system).
# Absolute path. Reverse proxy, API container and updater then read the same
# directory — otherwise this page describes a different store than the one
# being served.
TLS_CERTIFICATE_HOST_PATH=/etc/ssl/calserver

Then redeploy (cd /var/calserver && ./deploy.sh). The files must carry the name the reverse proxy looks for: <vhost>.crt and <vhost>.key, e.g. metweb.example.com.crt. A wildcard certificate goes under the name of the level it covers (example.com.crt).

The banner says "no certificates" although HTTPS works?

Then the application reads a different directory than the reverse proxy serves — the classic case when certificates were mounted into a container by hand. That is why the banner names the path it read, and the line below it counts the files found. If the path is wrong, TLS_CERTIFICATE_HOST_PATH is the place to fix it.

TLS_EXPIRY_WARNING_DAYS also shifts the warning window (21 days by default). More lead time makes sense for a self-maintained chain because procurement and approval add to it — Let's Encrypt renews on its own.

Afterwards

The reverse proxy reloads the file immediately. If that fails, the message says so: the file is in place but not yet served. Its log helps in that case:

docker exec nginx-proxy nginx -t
docker logs nginx-proxy --tail 50

Going back to automatic issuance is the same path in reverse: remove ACME_*_HOST from the .env and redeploy. The ACME service then takes the name back. There is no UI for it — the operation is rare and a decision, not a routine.

Kubernetes

There a completely separate path applies: cert-manager issues, a ClusterIssuer decides the source, and the certificate sits in a secret at the ingress. The directory this page reads does not exist — it then reports state cannot be determined with the reason, instead of passing emptiness off as "no certificate". calServer steers nothing there, and the page should say so.

Not on this page

  • Seals and report signing. Signing calibration certificates has its own management with its own rules: Setting up seals. The difference matters — a seal's private key lives in the seal service (calserver-seal), not in the database, and it does not leave it, not even into a backup (ADR-042).
  • Triggering a renewal of an automatically issued certificate. That is the ACME service's decision alone.
  • Triggering a renewal. That is the ACME service's decision alone. If a renewal does not happen, the reason is in its log:
docker logs letsencrypt-nginx-proxy --tail 100
docker exec nginx-proxy ls -la /etc/nginx/certs/