Skip to content

Setup Behind a Reverse Proxy

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

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.

What you get

  • 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:1323 with 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.
  • 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.

Before you start

  • 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 /session and /favicon.svg, so a proxy location such as /webmail/ does not work. ALPS's cookies are set for Path=/, 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).

Configuration

ALPS: /etc/alps/config.toml

[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 = true

Notes on this file:

  • This file holds only what the proxy setup needs. A production file also sets login_key and authserv_ids, and keeps debug off: see Running-in-Production.
  • There is no [tls] section. [tls] enabled defaults to false, and it must stay off behind a proxy that terminates TLS.
  • [plugin.base] enabled = true turns on the enabled list of plugins, so only base, which always loads, is on. Add a section with enabled = true for every other plugin you use: while the list is on, a section that leaves enabled out 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 /32 or /128, and an entry that does not parse stops ALPS at startup with invalid trusted_proxies IP/CIDR.
  • If the proxy runs on another host, set addr to an address the proxy can reach, such as 10.0.0.5:1323, and list the proxy's address as ALPS sees it, such as trusted_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-For from the right, skips the addresses in trusted_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 without Secure, and the rate limits and the log see every request as coming from the proxy's address. Use trusted_proxies when 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 in origins.

The keys are described on Config-Server and Config-Rate-Limiting-and-WebAuthn.

nginx: /etc/nginx/conf.d/webmail.example.com.conf

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 -t stops at it as an unknown directive: remove the line and write listen 443 ssl http2; and listen [::]:443 ssl http2; instead. On a host without IPv6, remove the two listen [::]:… lines.
  • X-Forwarded-Proto $scheme sends https. ALPS then expects the origin https://webmail.example.com and marks its cookies Secure.
  • X-Forwarded-Host $http_host keeps the port the browser used. $host drops it, which breaks the origin check when the site runs on a port other than 443.
  • X-Forwarded-For $proxy_add_x_forwarded_for appends the address nginx sees. ALPS reads the list from the right and skips the addresses in trusted_proxies, so it uses the address nginx added and not any address the browser sent itself. X-Real-IP is read only when X-Forwarded-For gives no address.
  • Origin is passed through unchanged. Don't rewrite it to make the origin check pass, because the check compares the Origin the browser sent.
  • client_max_body_size 33m: nginx's default of 1m refuses every upload over 1 MiB with 413 before ALPS sees it. Set it to max_attachment_mib + 1 MiB, and no lower than 32 MiB, the largest message body ALPS accepts. With max_attachment_mib = 50, that is 51m. See docs/ATTACHMENTS.md.
  • The event stream needs no settings. ALPS answers /events with X-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 default proxy_read_timeout of 60 seconds. Don't add proxy_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 file at warn level. ALPS then reads the upload in milliseconds, so its read_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-Control and X-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, add add_header Strict-Transport-Security "max-age=31536000" always; to the listen 443 server block. Add it once the HTTPS setup works, because browsers keep the rule for max-age seconds. This line was not part of the test.

Caddy: /etc/caddy/Caddyfile

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 = 120

With 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.

Start it

systemctl restart alps
journalctl -u alps -n 20

# nginx
nginx -t && systemctl reload nginx

# or Caddy
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy

ALPS 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).

Check it works

  1. 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 200 and an x-alps-build header. The second should show a 301 (nginx) or 308 (Caddy) to https://webmail.example.com/. GET / needs no session, so it also works as a liveness probe for the proxy.

  2. 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 in ip:

    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 ip is 127.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-cookie lines for alps_session, alps_logged_in, alps_login_token and alps_has_login_token, each with Secure. Without login_key, as in the file above, the last two come with an empty value and a date in the past, which clears them; with login_key they carry the login token. A 403 means the origin check refused the request.

  3. 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 print event: watching and data: INBOX right away, then : still here every 20 seconds. Send a message to the account: event: inbox follows 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 /events with status 200 that stays open.

  4. An upload near the limit. The limit is max_attachment_mib (32 MiB by default), unless the mail server advertises a smaller message size (IMAP APPENDLIMIT or SMTP SIZE): ALPS then lowers it to what fits in such a message once encoded, and curl -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, change composer_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
  5. 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.

Troubleshooting

Sign-in fails with "Invalid origin"

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_proxies and 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_host instead of $host for X-Forwarded-Host.

Every log line shows 127.0.0.1

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).

Sign-in works but the cookies have no Secure

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.

New mail shows up only at the check interval

Work through these checks in order:

  • GET /settings shows "PushNewMail":true. When push is off, /events answers 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 on localhost.
  • curl -sN --http1.1 -b alps-cookies.txt https://webmail.example.com/events prints event: watching right away. If it prints nothing, something between the browser and ALPS is holding the answer back. In the test, nginx with proxy_ignore_headers X-Accel-Buffering sent 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: unavailable and the page goes back to checking at its interval.
  • In a cluster, the stream reaches the node that holds the session (see Setup-Cluster).

Uploads fail

  • nginx answers 413 with its own "413 Request Entity Too Large" page, and its error log says client intended to send too large body. Raise client_max_body_size to max_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 is max_attachment_mib, or less when the mail server advertises a smaller message size (MaxAttachmentMiB in GET /session shows it). Raise max_attachment_mib if 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 than read_timeout_sec, and ALPS logs an ERROR line upload multipart: ... that names the timeout. Raise read_timeout_sec and write_timeout_sec as described in Caddy, or use request_buffers.
  • With Caddy, the browser gets a 502 while ALPS logs status 200 for the upload. write_timeout_sec is 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. Keep write_timeout_sec at least as long as read_timeout_sec.

Adding a security key fails

The page shows "There was an error registering your security key. Please try again."

  • If ALPS logs failed to finish registration: Error validating origin, origins does not list the exact origin the browser uses: scheme, hostname, and the port when it is not 443, with no trailing slash. If rpid is set and origins is left out, origins keeps its localhost defaults, and ALPS warns at startup for each of them, for example WebAuthn: 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", set rpid. The browser refuses the key before it reaches ALPS.

Restart ALPS after you change either key.

Caddy logs "aborting with incomplete response" for /events

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.

Tested with

ALPS main a5443047 (linux/arm64, on Debian trixie), tested on 2026-11-12:

  • Proxies: nginx 1.31.8 (the nginx:alpine image, Alpine 3.24.4, OpenSSL 3.5.9) and Caddy v2.11.9 (the caddy:2-alpine image).
  • 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.test with a self-signed certificate in place of webmail.example.com, and gave Caddy that certificate with a tls line. 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.

Clone this wiki locally