Repository navigation
Setup Behind a Reverse Proxy
This page sets up ALPS behind nginx or Caddy on the same host. The proxy holds the certificate and serves HTTPS, and ALPS listens on plain HTTP on the loopback address. It covers what ALPS needs from the proxy: trusted_proxies and the forwarded headers, the CSRF origin check, WebAuthn's rpid and origins, the new-mail event stream, upload size limits and timeouts. It gives a complete nginx server block and a complete Caddyfile. To have ALPS serve HTTPS itself, see Let's Encrypt without a proxy and TLS configuration. The reference for proxies is the Reverse Proxies section of docs/TLS.md.
- Browsers reach
https://webmail.example.com. The proxy redirects plain HTTP on port 80 to HTTPS, because ALPS does not do that itself. - ALPS listens on
127.0.0.1:1323with its built-in TLS off, so only the proxy on the same host can reach it. - ALPS takes the browser's scheme, host and address from headers that the proxy sets, and accepts those headers only from the proxy. As a result:
- the CSRF origin check accepts the browser's
Origin: https://webmail.example.com, so signing in, sending and every other change work; - the session cookies are marked
Secure; - the sign-in rate limits and the log count each browser by its own address instead of the proxy's.
- the CSRF origin check accepts the browser's
- Open mail pages hear about new mail in the Inbox as soon as the mail server reports it. This comes over an event stream (
GET /events) that stays open and goes through the proxy unbuffered. - Attachments up to
max_attachment_mib(32 MiB by default) upload through the proxy, or up to the lower limit that the mail server's advertised message size allows (see An upload near the limit). - Security keys (two-factor sign-in) work under
webmail.example.com.
- ALPS installed on the host as described in Running-in-Production. The shipped systemd unit (
dist/alps.service) runs/usr/local/bin/alps -config /etc/alps/config.toml. - A DNS name, here
webmail.example.com, that points at the host, with ports 80 and 443 open to browsers. Port 1323 does not need to be open, because ALPS listens on the loopback address only. - A hostname of ALPS's own, served from its root. The web app asks for its pages and API at absolute paths such as
/sessionand/favicon.svg, so a proxy location such as/webmail/does not work. ALPS's cookies are set forPath=/, so another application on the same hostname would receive them too. - For nginx, a certificate and key for that name. The example uses the paths where certbot keeps them, under
/etc/letsencrypt/live/webmail.example.com/. Caddy obtains its certificate automatically. - A mail server that ALPS signs in to over IMAP and SMTP (see Mail-Server-Requirements). New-mail push needs IMAP IDLE. It also keeps a second IMAP connection open for every open mail page, and all of them come from ALPS's one address, so check the mail server's per-user and per-address connection limits. docs/CONFIGURATION.md, "Pushing new mail" explains the cost. If you don't want push, leave out
push_new_mail. - Settle the hostname before users add security keys. A key works only under the hostname it was added under (
rpid).
[server]
# Plain HTTP on the loopback address: only the proxy on this host reaches ALPS.
addr = "127.0.0.1:1323"
# The proxy's own addresses. ALPS reads X-Forwarded-For and X-Real-IP (the
# client's address) and X-Forwarded-Proto and X-Forwarded-Host (the browser's
# scheme and host) only from these peers.
trusted_proxies = ["127.0.0.1/32", "::1/128"]
# Tell open mail pages about new mail over GET /events (needs IMAP IDLE).
push_new_mail = true
[webauthn]
# The hostname and origin the browser uses. Neither is taken from the
# proxy's headers.
rpid = "webmail.example.com"
origins = ["https://webmail.example.com"]
[provider]
type = "imap"
[provider.imap]
server = "imaps://mail.example.com:993"
[smtp]
server = "smtps://mail.example.com:465"
[plugin.base]
enabled = trueNotes on this file:
- This file holds only what the proxy setup needs. A production file also sets
login_keyandauthserv_ids, and keepsdebugoff: see Running-in-Production. - There is no
[tls]section.[tls] enableddefaults tofalse, and it must stay off behind a proxy that terminates TLS. -
[plugin.base] enabled = trueturns on the enabled list of plugins, so onlybase, which always loads, is on. Add a section withenabled = truefor every other plugin you use: while the list is on, a section that leavesenabledout keeps its plugin out (see Config-Plugins). - ALPS warns at startup about every key it does not know, for example
Unknown configuration key: server.trusted_proxy (ignored), and keeps that key's default. Check the startup log after each change. - List only your own proxies in
trusted_proxies. A bare address counts as/32or/128, and an entry that does not parse stops ALPS at startup withinvalid trusted_proxies IP/CIDR. - If the proxy runs on another host, set
addrto an address the proxy can reach, such as10.0.0.5:1323, and list the proxy's address as ALPS sees it, such astrusted_proxies = ["10.0.0.2/32"]. Allow only the proxy through the firewall to that port. - If more than one proxy is in the chain, such as a load balancer in front of nginx, list every hop. ALPS reads
X-Forwarded-Forfrom the right, skips the addresses intrusted_proxies, and takes the first one that is not listed to be the client. -
trusted_origins = ["https://webmail.example.com"]is the fallback for a proxy that cannot set the forwarded headers. It covers only the origin check: cookies are then issued withoutSecure, and the rate limits and the log see every request as coming from the proxy's address. Usetrusted_proxieswhen you can. - If the proxy listens on a port other than 443, the browser's origin includes that port, for example
https://webmail.example.com:8443. Use the same value inorigins.
The keys are described on Config-Server and Config-Rate-Limiting-and-WebAuthn.
server {
listen 80;
listen [::]:80;
server_name webmail.example.com;
# ALPS does not redirect plain HTTP itself.
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name webmail.example.com;
ssl_certificate /etc/letsencrypt/live/webmail.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/webmail.example.com/privkey.pem;
# Uploads: max_attachment_mib + 1 MiB, and no less than 32 MiB.
client_max_body_size 33m;
location / {
proxy_pass http://127.0.0.1:1323;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}What each part does:
-
http2 on;needs nginx 1.25.1 or later. On an older nginx,nginx -tstops at it as an unknown directive: remove the line and writelisten 443 ssl http2;andlisten [::]:443 ssl http2;instead. On a host without IPv6, remove the twolisten [::]:…lines. -
X-Forwarded-Proto $schemesendshttps. ALPS then expects the originhttps://webmail.example.comand marks its cookiesSecure. -
X-Forwarded-Host $http_hostkeeps the port the browser used.$hostdrops it, which breaks the origin check when the site runs on a port other than 443. -
X-Forwarded-For $proxy_add_x_forwarded_forappends the address nginx sees. ALPS reads the list from the right and skips the addresses intrusted_proxies, so it uses the address nginx added and not any address the browser sent itself.X-Real-IPis read only whenX-Forwarded-Forgives no address. -
Originis passed through unchanged. Don't rewrite it to make the origin check pass, because the check compares theOriginthe browser sent. -
client_max_body_size 33m: nginx's default of1mrefuses every upload over 1 MiB with 413 before ALPS sees it. Set it tomax_attachment_mib+ 1 MiB, and no lower than 32 MiB, the largest message body ALPS accepts. Withmax_attachment_mib = 50, that is51m. See docs/ATTACHMENTS.md. -
The event stream needs no settings. ALPS answers
/eventswithX-Accel-Buffering: no, which tells nginx to pass each event on immediately. ALPS also writes a heartbeat every 20 seconds, which keeps the stream inside nginx's defaultproxy_read_timeoutof 60 seconds. Don't addproxy_ignore_headers X-Accel-Buffering. -
Uploads need no timeouts. nginx receives the whole upload before passing it on, and writes large ones to a temporary file. While it does, nginx logs
a client request body is buffered to a temporary fileat warn level. ALPS then reads the upload in milliseconds, so itsread_timeout_sec(10 seconds by default) does not limit slow uploads. The nginx host needs disk space for the uploads that are in progress. -
ALPS sets its own headers. It compresses the frontend and sets its own cache headers,
Content-Security-Policy,X-Content-Type-Options,X-DNS-Prefetch-ControlandX-Alps-Build. The proxy should pass these through and not replace them. -
HSTS is the proxy's job. ALPS does not send
Strict-Transport-Security. To have browsers use only HTTPS for the name once they have seen it, addadd_header Strict-Transport-Security "max-age=31536000" always;to thelisten 443server block. Add it once the HTTPS setup works, because browsers keep the rule formax-ageseconds. This line was not part of the test.
webmail.example.com {
reverse_proxy 127.0.0.1:1323
}Caddy obtains the certificate itself, and redirects plain HTTP to HTTPS with a 308. reverse_proxy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host and passes the original Host through, so you don't need any header lines. You don't need a body-size setting either: ALPS refuses an upload larger than its attachment limit (max_attachment_mib, or less, see below) + 1 MiB with a 413 of its own. Caddy passes each event of the stream on immediately and keeps the stream open.
Caddy passes an upload on to ALPS as it arrives, which nginx does not. The whole upload must therefore reach ALPS within read_timeout_sec, which is 10 seconds by default; a slower one is refused with 408 upload_timed_out. In that time, a 32 MiB file needs an upload speed of about 27 Mbit/s. With Caddy, add these lines to the [server] section of config.toml:
# Caddy passes uploads on as they arrive: an upload must reach ALPS within
# read_timeout_sec, and its answer leave within write_timeout_sec.
read_timeout_sec = 120
write_timeout_sec = 120With 120 seconds, a 32 MiB upload works at about 2.2 Mbit/s. Keep write_timeout_sec at least as long as read_timeout_sec. It runs from the end of the request headers to the end of the response, so a shorter one cuts off the answer to an upload that ALPS has already stored. The event stream is not affected by either timeout.
Alternatively, you can have Caddy read the upload before passing it on, and leave ALPS's timeouts at their defaults. Caddy then holds each upload in progress in memory, up to 34 MiB per upload:
webmail.example.com {
reverse_proxy 127.0.0.1:1323 {
request_buffers 34MiB
}
}Caddy does not add Strict-Transport-Security either. To send it, add header Strict-Transport-Security "max-age=31536000" inside the site block, with the same care as for nginx. This line was not part of the test.
An HAProxy example is in docs/TLS.md.
systemctl restart alps
journalctl -u alps -n 20
# nginx
nginx -t && systemctl reload nginx
# or Caddy
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddyALPS logs Starting server on 127.0.0.1:1323. If [webauthn] rpid is unset, it also warns at startup that 2FA will work only from localhost. systemctl reload alps only reopens the log file, so restart ALPS after every change to config.toml (see docs/OPERATIONS.md).
-
HTTPS and the redirect.
curl -sI https://webmail.example.com/ | grep -i -E '^(HTTP|x-alps-build)' curl -sI http://webmail.example.com/ | grep -i -E '^(HTTP|location)'
The first command should show
HTTP/2 200and anx-alps-buildheader. The second should show a 301 (nginx) or 308 (Caddy) tohttps://webmail.example.com/.GET /needs no session, so it also works as a liveness probe for the proxy. -
Sign-in and the client address. Sign in at
https://webmail.example.com. With the default logging (JSON to the journal), the sign-in line should show your own address inip:journalctl -u alps | grep '"msg":"sign-in"' | tail -1
{"time":"…","level":"INFO","msg":"sign-in","user":"you@example.com","ip":"203.0.113.10","session":"…","2fa":false,…}If
ipis127.0.0.1, ALPS is not reading the proxy's headers (see Troubleshooting).You can also check from the command line. Use a test account without a security key, and note that every attempt counts against the sign-in rate limits (5 a minute from one address by default):
curl -s -D - -o /dev/null -c alps-cookies.txt \ -H 'Origin: https://webmail.example.com' -H 'Content-Type: application/json' \ -d '{"username":"test@example.com","password":"test-password"}' \ https://webmail.example.com/session
Expect a 200 and
set-cookielines foralps_session,alps_logged_in,alps_login_tokenandalps_has_login_token, each withSecure. Withoutlogin_key, as in the file above, the last two come with an empty value and a date in the past, which clears them; withlogin_keythey carry the login token. A 403 means the origin check refused the request. -
The event stream.
curl -s -b alps-cookies.txt https://webmail.example.com/settings | grep -o '"PushNewMail":[a-z]*' curl -sN -b alps-cookies.txt https://webmail.example.com/events
The first command should print
"PushNewMail":true. The second should printevent: watchinganddata: INBOXright away, then: still hereevery 20 seconds. Send a message to the account:event: inboxfollows as soon as the mail server reports the arrival. In the test, sora reported it 8 to 15 seconds after delivery (it checks a mailbox in IDLE every 15 seconds). In a browser, keep the Inbox open and send yourself a message. It should appear without a reload, and the developer tools' Network panel should show a request to/eventswith status 200 that stays open. -
An upload near the limit. The limit is
max_attachment_mib(32 MiB by default), unless the mail server advertises a smaller message size (IMAPAPPENDLIMITor SMTPSIZE): ALPS then lowers it to what fits in such a message once encoded, andcurl -s -b alps-cookies.txt https://webmail.example.com/session | grep -o '"MaxAttachmentMiB":[0-9.]*'shows the value. In the composer, attach a file just under 32 MiB, or just under that value. You can also upload from the command line:head -c 33554432 /dev/urandom > 32MiB.bin curl -s -b alps-cookies.txt -H 'Origin: https://webmail.example.com' \ -F attachments=@32MiB.bin 'https://webmail.example.com/attachments?composer_id=check'
The answer should be a JSON array with one ID. With a lower
MaxAttachmentMiB, use a file under that size: a larger one is refused. To repeat the upload in the same session, changecomposer_id: a second 32 MiB file for the same composer goes over its budget and is refused with 400. The upload is held in the session until the session ends, so sign out afterwards:curl -s -b alps-cookies.txt -X DELETE -H 'Origin: https://webmail.example.com' \ https://webmail.example.com/session -
A security key. With a test account, open Settings > 2FA / WebAuthn, select Add Security Key, name the key, and select Save. The first key comes with recovery codes, which are shown once. Sign out and sign in again: after the password, the browser asks for the key. For what users and operators do when a key is lost, see Security-Features and docs/WEBAUTHN.md.
POST /session answers 403 {"error":"Invalid origin","code":403,"status":"Forbidden"}, and ALPS logs a line like this one:
CSRF: Origin mismatch: got "https://webmail.example.com", expected "http://webmail.example.com" (scheme differs and the peer 127.0.0.1 is not in trusted_proxies; behind a TLS-terminating proxy, forward X-Forwarded-Proto and list the proxy in trusted_proxies, or set trusted_origins)
The text in parentheses names the cause:
-
"the peer … is not in trusted_proxies": add the address it names to
trusted_proxiesand restart ALPS. - "the trusted proxy is not sending X-Forwarded-Proto", or "forwards X-Forwarded-Proto "http"": fix that header in the proxy.
-
"hosts differ only by port": the proxy forwards the host without the port the browser used. In nginx, use
$http_hostinstead of$hostforX-Forwarded-Host.
The ip field holds the proxy's address because ALPS is not reading the forwarded headers. Either trusted_proxies is missing, or it does not list the address the proxy connects from. Until you fix it, every user shares one address's sign-in limits. By default, that means 5 sign-in attempts a minute for the whole site. Once 20 attempts in an hour have been refused with no successful sign-in in between, the next attempt locks everyone out for 15 minutes (docs/RATELIMIT.md).
The origin is accepted through trusted_origins, but ALPS cannot tell that the browser uses HTTPS: either the proxy is not in trusted_proxies, or it does not send X-Forwarded-Proto: https. Add the proxy to trusted_proxies and check that header.
Work through these checks in order:
-
GET /settingsshows"PushNewMail":true. When push is off,/eventsanswers 404 and the page does not open a stream. - The site is served over HTTPS. The page opens the stream only where the browser offers
navigator.locks, which browsers do only over HTTPS and onlocalhost. -
curl -sN --http1.1 -b alps-cookies.txt https://webmail.example.com/eventsprintsevent: watchingright away. If it prints nothing, something between the browser and ALPS is holding the answer back. In the test, nginx withproxy_ignore_headers X-Accel-Bufferingsent nothing over HTTP/1.1 for 50 seconds, not even the headers. Remove such a line. For any other proxy or CDN in front, turn off response buffering for/events. - No proxy in the chain closes a connection after less than 20 seconds of silence, because ALPS's heartbeat comes every 20 seconds.
- The mail server offers IDLE and accepts the session's second connection (see Mail-Server-Requirements). When the mail server cannot report changes, the stream says
event: unavailableand the page goes back to checking at its interval. - In a cluster, the stream reaches the node that holds the session (see Setup-Cluster).
-
nginx answers 413 with its own "413 Request Entity Too Large" page, and its error log says
client intended to send too large body. Raiseclient_max_body_sizetomax_attachment_mib+ 1 MiB. -
ALPS answers
Your attachments exceed the maximum file size: with 413 when the upload is larger than the attachment limit + 1 MiB, and with 400 when the files do not fit what the composer, the session or the server may still hold. The limit ismax_attachment_mib, or less when the mail server advertises a smaller message size (MaxAttachmentMiBinGET /sessionshows it). Raisemax_attachment_mibif you want larger attachments, and raise the proxy's limit with it; a limit set by the mail server is raised there. The budgets are described in docs/ATTACHMENTS.md. -
With Caddy, large uploads fail after 10 seconds with 408
{"code":"upload_timed_out","error":"Upload timed out. Try again."}. The upload took longer thanread_timeout_sec, and ALPS logs an ERROR lineupload multipart: ...that names the timeout. Raiseread_timeout_secandwrite_timeout_secas described in Caddy, or userequest_buffers. -
With Caddy, the browser gets a 502 while ALPS logs
status 200for the upload.write_timeout_secis shorter than the upload took, so ALPS stored the file but its answer was cut off. The file stays in the session, counting against its attachment budget, until the session ends. Keepwrite_timeout_secat least as long asread_timeout_sec.
The page shows "There was an error registering your security key. Please try again."
- If ALPS logs
failed to finish registration: Error validating origin,originsdoes not list the exact origin the browser uses: scheme, hostname, and the port when it is not 443, with no trailing slash. Ifrpidis set andoriginsis left out,originskeeps its localhost defaults, and ALPS warns at startup for each of them, for exampleWebAuthn: origin "http://localhost:1323" is outside rpid "webmail.example.com"; security-key enrolment will fail; set [webauthn] origins to the browser-facing origin. - If ALPS logs nothing at that moment but warned at startup with
WebAuthn: [webauthn] rpid is unset, defaulting to "localhost", setrpid. The browser refuses the key before it reaches ALPS.
Restart ALPS after you change either key.
Caddy writes this line at warn level when a page's event stream ends from the browser's side, for example when a tab closes or reloads. It is harmless.
ALPS main a5443047 (linux/arm64, on Debian trixie), tested on 2026-11-12:
-
Proxies: nginx 1.31.8 (the
nginx:alpineimage, Alpine 3.24.4, OpenSSL 3.5.9) and Caddy v2.11.9 (thecaddy:2-alpineimage). - Mail server: a sora development build, used for IMAP and SMTP.
- Clients: curl 8.7.1, and Chromium 151.0.7922.34 driven by Playwright 1.62.0 for the browser checks (push and security keys).
- Host: Docker Desktop 4.97.0 (Engine 29.9.2) on macOS, arm64.
-
Lab differences: the lab used
webmail.example.testwith a self-signed certificate in place ofwebmail.example.com, and gave Caddy that certificate with atlsline. It also reached the mail server over plain IMAP and SMTP.
Updated from the code at main 92ec0e11 without a rerun: the plugin rules and the startup warning for unknown keys in the notes on the ALPS file, the attachment limit that a mail server's advertised message size lowers, the 408 answer and log line for an upload that outlasts read_timeout_sec, and the startup warning for origins left at their defaults. The ALPS file above, alone and with the Caddy timeouts, was started once with a 92ec0e11 build on macOS, to check that ALPS knows every key and loads only base.
These pages describe ALPS as it is on the main branch; the reference documents live in the repository's docs/ directory.
Architecture
- Architecture Overview
- Mail Server Requirements
- Sessions and Sign-in
- Storage in IMAP METADATA
- Mail Providers
- Plugins
- Features
- Security Features
- Clustering
Tests
Configuration
- Configuration Overview
- Server
- Providers and SMTP
- TLS
- Cluster
- Rate Limiting and WebAuthn
- Logging and Cache
- Plugins
- Branding
Common Setups