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:
- Set the variable in the
.envof the Docker directory. - 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.
- Redeploy — only then does the container receive the changed environment:
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:
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:
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:
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:
Related topics¶
- Operations — the page this tab lives on
- Setting up seals — signing calibration certificates
- Configuration — the environment variables at a glance