Skip to content

Setup Lets Encrypt Standalone

Dejan Strbac edited this page Nov 12, 2026 · 1 revision

This page sets up ALPS on one host that serves HTTPS itself, with a certificate from Let's Encrypt and no reverse proxy in front. ALPS listens on ports 443 and 80, orders the certificate, stores it on local disk and renews it without a restart. The page uses the Let's Encrypt staging directory first, then switches to production, and lists what to check and the errors you may meet. For the meaning of every TLS key, see Config-TLS. To put a proxy in front instead, see Setup-Behind-a-Reverse-Proxy. For several nodes sharing certificates, see Setup-Cluster.

What you get

  • https://webmail.example.com served by ALPS on port 443, with TLS 1.2 or later and HTTP/2.
  • A certificate that ALPS orders from Let's Encrypt by itself, about one to two minutes after the first start, and renews while it runs. No cron job and no restart are needed.
  • The certificate, its private key and the ACME account key in /var/lib/alps/cert-cache, readable only by the alps user.
  • A listener on port 80 that answers Let's Encrypt's HTTP-01 challenge and nothing else. Requests for any other path get 404. ALPS does not redirect http:// to https://.
  • Session cookies marked Secure, because the browser reaches ALPS over HTTPS directly. trusted_proxies is not needed.

Before you start

  • A host name. Its A record, and its AAAA record if it has one, point at this host. The examples use webmail.example.com.
  • Ports 80 and 443 are free on the host and open from the internet. Let's Encrypt checks the name either on port 443 (the TLS-ALPN-01 challenge, answered on [server] addr) or on port 80 (the HTTP-01 challenge, answered on acme_http_addr). ALPS tries TLS-ALPN-01 first and HTTP-01 second. Opening both lets either one succeed.
  • Outbound HTTPS to acme-v02.api.letsencrypt.org, and to acme-staging-v02.api.letsencrypt.org while you test, plus the connections to your mail server (OPERATIONS.md, Outbound Connections).
  • The system's CA certificates, for example the ca-certificates package on Debian. ALPS checks Let's Encrypt's API server against them.
  • The ALPS binary at /usr/local/bin/alps, built as the README describes (make build-linux writes a static amd64 binary, build/alps-linux-amd64), and the systemd unit dist/alps.service from the repository.
  • An IMAP and an SMTP server that ALPS can reach. See Mail-Server-Requirements and Config-Providers-and-SMTP.

Configuration

/etc/alps/config.toml

[server]
# HTTPS. Let's Encrypt's TLS-ALPN-01 challenge also arrives here.
addr = ":443"

# Security keys are bound to the name browsers use. ALPS does not take it
# from the TLS settings.
[webauthn]
rpid = "webmail.example.com"
origins = ["https://webmail.example.com"]

[provider]
type = "imap"

[provider.imap]
server = "imaps://mail.example.com"     # port 993 by default

[smtp]
server = "smtps://mail.example.com"     # port 465 by default

[tls]
enabled = true
provider = "letsencrypt"

[tls.letsencrypt]
email = "admin@example.com"             # ACME account contact (required)
domains = ["webmail.example.com"]       # every name browsers use (required)
storage_provider = "file"               # local disk; the default is "s3"
cache_dir = "/var/lib/alps/cert-cache"  # absolute path; the default "cert-cache" is relative
# acme_http_addr = ":80"                # the default: HTTP-01 challenges on port 80

# Staging first. Delete this line to switch to production.
directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"

Notes on these settings:

  • storage_provider = "file" keeps the certificates on local disk. Without it, ALPS expects an S3 bucket and does not start.
  • cache_dir must be absolute. The shipped unit runs ALPS in / with a read-only file system, so a relative directory cannot be written.
  • More names: list each one in domains and point its DNS at the host. Each name gets a certificate of its own. A client that sends no name (for example, one opening https://<IP address>/) gets the certificate of default_domain, which defaults to the first entry of domains.
  • With a staging or any other non-production directory_url, ALPS keeps its certificates in a directory of their own. For the staging URL above, that is /var/lib/alps/cert-cache-acme-67f27506, so staging and production certificates never mix (TLS.md, Staging Directory).

The configuration holds no secrets yet. Once you add [server] login_key or a password-plugin token, keep the file readable only by root and the alps group, as below.

systemd drop-in

The shipped unit grants CAP_NET_BIND_SERVICE, so ALPS can listen on ports 80 and 443 as the unprivileged alps user. The unit's file system is read-only except for the paths in ReadWritePaths (OPERATIONS.md, Caveats), so the drop-in lets ALPS write its certificate store:

[Service]
ReadWritePaths=/var/lib/alps

The unit's TimeoutStopSec=60 already covers a stop with Let's Encrypt, which takes up to 40 seconds: up to 30 for requests in progress, 5 for the sessions' cleanup and 5 for the certificate workers. A unit copied from an earlier release has TimeoutStopSec=30. Copy the current dist/alps.service, or add TimeoutStopSec=60 to the drop-in.

Start it

Create the user, the certificate directory and the configuration, then install and start the unit:

useradd --system --no-create-home --shell /usr/sbin/nologin alps   # skip if the user exists
install -d -o alps -g alps -m 700 /var/lib/alps
install -d -m 755 /etc/alps
install -m 640 -o root -g alps config.toml /etc/alps/config.toml
cp dist/alps.service /etc/systemd/system/alps.service
systemctl daemon-reload
systemctl edit alps            # paste the drop-in above
systemctl enable --now alps
journalctl -u alps -f

After the lines about the SMTP server, rate limiting and the plugins (base and gpg, since this file has no [plugin.*] sections), a good start with the staging directory logs these lines. The certificate manager's lines follow [logging] like the others, so they are JSON by default. The last two lines can come in either order:

{"time":"…","level":"WARN","msg":"using non-default ACME directory - certificate store namespaced to keep it isolated from production","directory_url":"https://acme-staging-v02.api.letsencrypt.org/directory","cache_dir":"/var/lib/alps/cert-cache-acme-67f27506","s3_prefix":"acme-67f27506/"}
{"time":"…","level":"INFO","msg":"using file-based certificate cache","cache_dir":"/var/lib/alps/cert-cache-acme-67f27506"}
{"time":"…","level":"INFO","msg":"TLS running in single-instance mode (no cluster leader election)"}
{"time":"…","level":"INFO","msg":"TLS manager initialized","domains":["webmail.example.com"],"email":"admin@example.com","storage":"file","default_domain":"webmail.example.com"}
{"time":"…","level":"INFO","msg":"Starting ACME HTTP-01 challenge handler on :80"}
{"time":"…","level":"INFO","msg":"Starting server on :443"}

ALPS orders the certificate 60 to 120 seconds after it starts, in its first warm pass. A visit before then orders the certificate on the spot, and that connection waits for the order: once the browser's TLS hello names a listed domain, the handshake has two minutes for it, separate from [server] read_timeout_sec. An order that takes longer than that ends the connection, and the order still completes for the next visit.

Switch to production

When the staging checks below pass:

  1. Delete the directory_url line from /etc/alps/config.toml.
  2. Run systemctl restart alps. The using non-default ACME directory warning is gone, and cache_dir is /var/lib/alps/cert-cache.
  3. Wait two minutes, then run the checks again. The issuer's names no longer start with (STAGING).
  4. Delete /var/lib/alps/cert-cache-acme-67f27506 once you no longer need the staging certificate.

Check it works

The certificate is in service. The first warm pass, 60 to 120 seconds after the start, logs the state of each name's certificate once it has ordered or loaded it:

{"time":"…","level":"INFO","msg":"cert warmer: certificate is valid","domain":"webmail.example.com","flavor":"ecdsa","not_after":"…","remaining":…}

not_after is the expiry date. A failed order logs cert warmer: failed to obtain certificate instead (see Troubleshooting).

The certificate is stored. After about two minutes the store holds the account key and one file per name:

ls -l /var/lib/alps/cert-cache-acme-67f27506/   # /var/lib/alps/cert-cache/ in production
# -rw------- 1 alps alps … acme_account+key
# -rw------- 1 alps alps … webmail.example.com

The served certificate, from another machine:

openssl s_client -connect webmail.example.com:443 -servername webmail.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -issuer -dates -ext subjectAltName

The SAN is DNS:webmail.example.com. With staging, the issuer's organisation and common name start with (STAGING). Browsers do not trust staging certificates (Let's Encrypt staging environment), so a browser warning is expected until you switch to production. -ext needs OpenSSL 1.1.1 or later. The LibreSSL openssl on macOS lacks it, so leave it out there.

HTTPS in production. The certificate verifies against the system's CAs, and ALPS answers:

curl -s -o /dev/null -w '%{http_version} %{http_code}\n' https://webmail.example.com/
# 2 200

Port 80 reaches ALPS's challenge listener with the right name. A token that does not exist gets ALPS's own 404:

curl -s http://webmail.example.com/.well-known/acme-challenge/check
# acme/autocert: certificate cache miss

Any other answer means that something else holds port 80, or that a firewall or port forward is in the way. A plain http://webmail.example.com/ gets a 404 (404 page not found), because ALPS does not redirect to HTTPS. Link and bookmark the https:// address.

Sign-in. Open https://webmail.example.com and sign in. In the browser's developer tools, the alps_session cookie is marked HttpOnly, Secure and SameSite=Strict.

Renewal

There is nothing to schedule. ALPS keeps the certificate in memory and renews it on its own:

  • A certificate comes due when one third of its lifetime is left, at most 30 days before it expires, or up to an hour after that. ALPS orders the new one and serves it at once, without a restart, and writes it over the stored copy.
  • A renewal that fails is tried again 30 to 60 minutes later. Meanwhile ALPS serves the current certificate until it expires. It does not serve an expired one: from then on, handshakes for the name fail, and ALPS logs TLS: cached certificate is not valid at ERROR level with certificate expired at …, until a renewal succeeds.
  • Every 12 hours a warm pass makes sure that every name in domains has a certificate, and logs the state of each one. A certificate that ALPS already holds counts, even an expired one, so the pass does not repeat a failing renewal.
  • A restart reads the stored certificate and orders nothing while that certificate is valid and not yet due. For an expired stored certificate, ALPS orders a new one at the first connection or warm pass, as on a first start, and logs a failure as described under Troubleshooting.

ALPS logs no line of its own when a renewal runs. The warm passes report on it instead: each one logs, for every name, cert warmer: certificate is valid (INFO), cert warmer: certificate is due for renewal (WARN) or cert warmer: certificate has expired (ERROR), with not_after and remaining. A renewal runs up to an hour after it comes due, so one warning on its own is no cause for concern. The same warning at the next pass, 12 hours later, means that renewals are failing. Watch the log for due for renewal and has expired, and check the expiry date from outside as well, for example from a monitoring job:

openssl s_client -connect webmail.example.com:443 -servername webmail.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -checkend 864000
# exit status 0 while more than 10 days are left, 1 otherwise

OpenSSL also prints Certificate will not expire or Certificate will expire. The LibreSSL openssl on macOS prints nothing, so go by the exit status. With a 90-day certificate, the renewal comes due 30 days before expiry, so a -checkend failure at 10 days means that renewals have been failing for about 20 days. Work through the checks under no viable challenge type found (DNS, ports 443 and 80), and the outbound HTTPS and CA checks under Before you start. ALPS goes on retrying by itself.

Back up /var/lib/alps/cert-cache, and protect the backup as you would any private key. With storage_provider = "file" it is the only copy. If it is lost, ALPS orders every certificate again. Issuance and Renewal in TLS.md has the details.

Troubleshooting

ALPS exits at startup

Message Fix
Failed to initialize TLS manager: S3 bucket validation cancelled after 3 attempts: context deadline exceeded, followed by the S3 client's last error, such as get credentials: request canceled, about 30 seconds after validating S3 bucket access with "bucket":"" storage_provider is missing, so ALPS looks for an S3 bucket. Set storage_provider = "file". The number of attempts and the error vary with the host.
Failed to initialize TLS manager: email required Set email in [tls.letsencrypt].
Failed to build TLS config: unsupported tls provider: provider is missing or misspelt. It must be "letsencrypt", in lower case.
HTTP listener failed: listen tcp :443: bind: permission denied, after Starting server on :443 ALPS runs without permission to use ports below 1024. Start it through the shipped unit, which grants CAP_NET_BIND_SERVICE.

Each of these ends ALPS with exit status 1, and Restart=on-failure starts it again 5 seconds later, so the same line repeats in journalctl -u alps. Config-TLS lists the other startup errors.

ACME HTTP-01 handler error: listen tcp :80: bind: permission denied

ALPS keeps running, but HTTP-01 challenges cannot be answered, so only TLS-ALPN-01 on port 443 can succeed. The cause is the same as the 443 error above. If the line says address already in use instead, another program holds port 80. To keep that program on port 80, it must pass /.well-known/acme-challenge/ to ALPS (TLS.md, Passing ACME Challenges through a Proxy).

mkdir …: read-only file system or mkdir …: permission denied

The log line is an ERROR with "msg":"TLS: failed to get certificate", "server_name":"webmail.example.com" and "error":"mkdir …", or one with "msg":"cert warmer: failed to obtain certificate" and the same error. With staging, the directory in the message ends in -acme-67f27506. ALPS cannot write cache_dir:

  • read-only file system, for example mkdir cert-cache: read-only file system: cache_dir is relative, or it is outside the drop-in's ReadWritePaths. Set cache_dir = "/var/lib/alps/cert-cache" and add the drop-in.
  • permission denied, for example mkdir /var/lib/alps/cert-cache: permission denied: /var/lib/alps does not belong to alps. Started by hand rather than through the unit, ALPS reports mkdir /var/lib/alps: permission denied when the directory does not exist. Under the unit, a missing /var/lib/alps keeps the unit from starting at all, because every path in ReadWritePaths must exist. Create it with the install -d -o alps -g alps -m 700 line above.

The attempt ends before ALPS contacts Let's Encrypt, so it does not count against Let's Encrypt's limits. Restart ALPS after the fix.

x509: certificate signed by unknown authority

The log has an ERROR line cert warmer: failed to obtain certificate, or TLS: failed to get certificate when a visit started the order, whose error ends in Get "https://acme-…/directory": tls: failed to verify certificate: x509: certificate signed by unknown authority. ALPS could not verify Let's Encrypt's API server. Install the system's CA certificates (ca-certificates on Debian). Also check that no proxy on the way replaces the server's certificate.

no viable challenge type found

The error of a TLS: failed to get certificate or cert warmer: failed to obtain certificate line reads acme/autocert: unable to satisfy "https://…" for domain "webmail.example.com": no viable challenge type found. This is how the ACME library reports an order whose challenges all failed: ALPS tried both, and neither passed. The CA keeps the reason for each challenge in the authorization that the https://… URL names. Check, in order:

  • The name's A record, and AAAA record if any, point at this host.
  • Port 443 from the internet reaches ALPS directly, not a proxy that ends TLS.
  • Port 80 from the internet reaches ALPS, and the curl …/.well-known/acme-challenge/check test above answers acme/autocert: certificate cache miss. An answer of … not configured in HostWhitelist (403) means that a port forward or proxy changed the Host header. It must arrive as the bare name, without a port.

After a failed attempt, every connection for that name fails at once for one minute (acme/autocert: missing certificate in the log). The first connection after that minute starts a new attempt, and so does each warm pass. Let's Encrypt limits failed validations (rate limits), and one attempt can fail two challenges. Fix the cause while still on staging. In production, every visit after that minute starts another attempt, so do not announce the address until the certificate is stored.

The browser cannot connect for one name, or warns about the name

  • A name that is not in domains gets no certificate. The browser reports a TLS error (curl: tlsv1 alert internal error), and ALPS logs nothing. Add the name to domains, point its DNS at the host, and restart.
  • A listed name that has no certificate yet, for example in the minute after a failed order, gets the same TLS error. Here ALPS logs TLS: failed to get certificate at ERROR level, with the reason (see no viable challenge type found).
  • Opened by IP address, ALPS serves the default_domain certificate, and the browser warns that the name does not match. Use the host name.

Testing against Pebble instead of Let's Encrypt

Pebble, Let's Encrypt's ACME test server, differs from Let's Encrypt in two ways that matter to ALPS:

  • Pebble sends the HTTP-01 request with Host: <name>:80. ALPS's challenge listener accepts only the bare name and answers 403, so HTTP-01 fails against Pebble unless something in front removes the port. Let's Encrypt sends the bare name.
  • With Pebble 2.10.3, ALPS logs failed to obtain certificate … Post "": unsupported protocol scheme "" after Pebble has issued the certificate, and stores nothing. ALPS's ACME client expects a Location header on the answer to the order's finalization, which Let's Encrypt sends and Pebble 2.10.3 does not. Pebble 2.5.1 works.

To trust Pebble's API server on Linux, start ALPS with SSL_CERT_FILE pointing at Pebble's CA file. A macOS build ignores SSL_CERT_FILE and uses the system keychain.

Tested with

An ALPS build whose TLS, startup and session code is that of main a5443047 (Linux arm64 build, Go 1.26.5), on Debian 13.7 (trixie), run directly and under the shipped unit with systemd 257.13-1~deb13u1. Pebble 2.5.1 and pebble-challtestsrv (image of 2026-11-11) took the place of Let's Encrypt, all in Docker 29.9.2. The name was webmail.example.test, and Pebble validated TLS-ALPN-01 on port 443 and HTTP-01 on port 80. For the HTTP-01 runs, nginx 1.31.8 removed the port Pebble puts in the Host header. Client checks used OpenSSL 3.5.7 and curl 8.14.1. Renewal was watched with 6-minute certificates. The staging and production directory names were checked from ALPS's startup log, without contacting Let's Encrypt. The macOS build of the same code, on macOS 26.4, was used to check the startup lines, the port-80 answers, the startup errors, an unwritable store, and failed and stalled orders. With a short-lived certificate placed in the store and a local listener in place of the ACME directory, it was also used to check a restart, a failed renewal, expiry, and a restart after expiry. Client checks there used OpenSSL 4.0.3, LibreSSL 3.3.6 and curl 8.7.1. Dated 2026-11-12.

Updated from the code at main 92ec0e11 without a rerun: the two minutes a first visit's handshake has for the order, which replace the troubleshooting case of a first visit cut off after 10 seconds; the warm pass's line for each certificate, and how an expired certificate is no longer served and is reported; the startup S3 check's message, which now names its cause after about 30 seconds; and the HTTP listener failed line. The configuration file above was started once with a 92ec0e11 build on macOS, with local addresses in place of ports 443 and 80, to check that ALPS knows every key and to read the startup lines; without storage_provider it gave the S3 message in the table.

Clone this wiki locally