Self-Hosted SimpleLogin (2026)
Self-Hosted SimpleLogin (2026)
Section titled “Self-Hosted SimpleLogin (2026)”SimpleLogin is an email-alias service: you give it a
domain, generate per-site aliases like mountain-rhino-2941@simplymail.org,
and it forwards inbound mail to your real mailbox. Reply to the forwarded
message and SL sends out as the alias, so the other side never learns your
real address. The community self-hosts it under simplymail.org.
Status (2026-06-09): production, v4.81.2, public MX on
46.110.152.204, Authentik OIDC active, in-network delivery to Stalwart for*@irregulars.iorecipients. Settled in after the 2026-06-06 mail cutover (off GCP relay, onto direct Proxmox public IPs) and the 2026-06-09 cleanup (dormant CF Email Routing rule purge + multi-homed egress fix + SL→Stalwart transport bypass).
What’s in this guide
Section titled “What’s in this guide”| Section | What you’ll find |
|---|---|
| The alias model | How forward + reverse-alias replies work — the conceptual core |
| Architecture | Five containers, networks, ports |
| Public DNS layout | A/MX/SPF/DKIM/DMARC + the unproxied-MX rule |
| Public MX binding — 5 things that must line up | IP bind, DOCKER-USER, ports, FQDN |
| Authentik OIDC | Client config, alternative_id trap, apex-cookie-split fix (2026-06-08) |
| In-network delivery to Stalwart | NEW 2026-06-09 — transport_maps override for @irregulars.io |
| Reply gating | Authorized addresses, Apple Mail picker trap |
| Networking (host-level) | Multi-homed egress routing, CF dormant-rule audit |
| Operational gotchas | db-recreate pool invalidation, upgrade procedure |
| End-to-end verification | swaks + log-tail recipe |
| Operator commands | Day-to-day SQL + docker exec cheats |
This page is the operator’s guide. End-user-facing docs (how to make aliases, browser extensions, mobile apps) live at simplelogin.io. What’s documented here is what you can’t read in their docs: the self-hosting architecture as actually deployed, and the gotchas that have bitten us.
Why SimpleLogin (not just Cloudflare Email Routing)
Section titled “Why SimpleLogin (not just Cloudflare Email Routing)”| Need | Cloudflare Email Routing | SimpleLogin (self-hosted) |
|---|---|---|
Forward info@yourdomain to one inbox | ✅ free, trivial | overkill |
| Per-site disposable aliases at signup time | ❌ (manual creation each time) | ✅ purpose-built |
| Reply from the alias so the other party never sees your real email | ❌ | ✅ via reverse-alias mechanism |
| Self-hosted, no third party | ❌ (you’re on CF) | ✅ |
| Browser extension that mints aliases on the fly | ❌ | ✅ |
| Bulk alias dashboard, per-alias enable/disable, contact rules | ❌ | ✅ |
| MFA, OIDC SSO, audit log on the alias account itself | ❌ | ✅ |
CF Email Routing solves “I want info@ → my Gmail.” SimpleLogin solves
“every web service I sign up for gets a different address that I can
shut off without losing anything else.”
The two coexist fine on different domains: route info@yourdomain.com
via CF, mint disposable aliases at simplymail.org via SL.
The alias model
Section titled “The alias model”Internet sender (e.g. amazon@amazon.com) │ ▼ simplymail.org MX (46.110.152.204:25) │ sl-postfix: queue, validate, hand off to sl-email │ ▼ sl-email: - Lookup unwilling_legwork242@simplymail.org → Alias 16 - Lookup Alias 16 → Mailbox 2 (issac@alfaren.xyz) - Create/find Contact record for the original sender - REWRITE From: to a per-contact "reverse alias" e.g. <lpltvap…@simplymail.org> │ ▼ Outbound via Brevo SMTP relay → issac@alfaren.xyz
------------------------------------------------------------Reply path (when issac@alfaren.xyz hits "Reply"):
issac@alfaren.xyz │ replies to <lpltvap…@simplymail.org> ← the reverse alias ▼ simplymail.org MX (46.110.152.204:25) — same listener │ sl-email: - Lookup reverse alias → original Contact (amazon@amazon.com) - Verify MAIL FROM is one of Mailbox 2's authorized addresses - REWRITE From: → unwilling_legwork242@simplymail.org │ ▼ Outbound to amazon@amazon.com (they see only the alias, never the real mailbox)The reverse alias is the magic. Each (alias, contact) pair gets a
unique random-looking address — that’s how SL knows which alias and
which contact to use when you reply.
Architecture (what’s actually deployed)
Section titled “Architecture (what’s actually deployed)”| Container | Image | Role |
|---|---|---|
sl-postfix | simplelogin/postfix:latest | Public MX. Listens 46.110.152.204:25. Validates sender domain, accepts mail for any *@simplymail.org, hands off to sl-email. Brevo SMTP relay for outbound. |
sl-email | simplelogin/app-ci:v4.81.2 | The handler. Owns alias resolution, contact creation, reverse-alias generation, forward / reply logic. Listens on :20381 (internal), receives from sl-postfix at sl-email:20381. |
sl-app | simplelogin/app-ci:v4.81.2 | The Flask web app. app.simplymail.org — login (Authentik OIDC), dashboard, alias creation. Listens 127.0.0.1:7777, fronted by Cloudflare Tunnel. |
sl-job-runner | simplelogin/app-ci:v4.81.2 | Cron/queue worker — sends notification emails (e.g. “spoof attempt blocked”), processes deferred work. |
sl-db | postgres:16-alpine | Backing database (users, mailboxes, aliases, contacts, email_logs). Bridge-internal only. |
All five containers live on docker network sl-network = 10.1.0.0/24
on the Proxmox host 100.87.20.88.
Outbound deliveries from sl-email/sl-postfix flow via Brevo SMTP relay (smtp-relay.brevo.com:587, RELAY_HOST in sl-postfix env). Brevo has warm reputation and handles deliverability so we don’t have to manage IP rep for a low-volume alias service.
Public DNS layout
Section titled “Public DNS layout”A mx.simplymail.org 46.110.152.204 ttl=300 proxied=falseMX simplymail.org mx.simplymail.org pri=10TXT simplymail.org "v=spf1 ip4:46.110.152.204 include:sendinblue.com ~all"TXT _dmarc.simplymail.org "v=DMARC1; p=quarantine; rua=mailto:..."CNAME brevo1._domainkey b1.simplymail-org.dkim.brevo.comCNAME brevo2._domainkey b2.simplymail-org.dkim.brevo.comCNAME app.simplymail.org <uuid>.cfargotunnel.com proxied=trueCNAME api.simplymail.org <uuid>.cfargotunnel.com proxied=trueCritical: mx.simplymail.org A record MUST be proxied=false
(gray cloud). The Cloudflare proxy is HTTP/HTTPS only — SMTP through
it doesn’t work. The dashboard creates new A records as proxied=true
by default; the API creates them as false. Verify after creating.
The dashboard app.simplymail.org and API api.simplymail.org go
through a Cloudflare Tunnel (proxied) — those are HTTP and fine to
proxy.
Public MX binding — the parts that have to line up
Section titled “Public MX binding — the parts that have to line up”Putting sl-postfix:25 on a public IP needs five separate pieces, each
of which fails independently if missing.
1. IP bound on the host’s vmbr0
Section titled “1. IP bound on the host’s vmbr0”The /29 secondary IP must actually be on vmbr0. If it’s only “routed
to us” but not bound, docker fails container start with
cannot assign requested address.
# Runtimeip addr add 46.110.152.204/29 dev vmbr0 label vmbr0:slarping -A -c 3 -I vmbr0 46.110.152.204 # gratuitous ARP so the upstream knows# Persist in /etc/network/interfaces:# up ip addr add 46.110.152.204/29 dev $IFACE label $IFACE:sl || true# up arping -A -c 3 -I $IFACE 46.110.152.204 2>/dev/null || true⚠️ Kernel IFNAMSIZ trap: interface labels max out at 15 chars
total. vmbr0:simplelogin = 17 chars → silent rejection. Use a short
suffix like vmbr0:sl.
2. DOCKER-USER ACCEPT for outbound from sl-network
Section titled “2. DOCKER-USER ACCEPT for outbound from sl-network”SimpleLogin’s docker bridge is 10.1.0.0/24. The host’s
DOCKER-USER chain ACCEPTs 172.16/12 for “Docker internal,” then
drops by default. 10.1.0.0/24 matches no ACCEPT and gets silently
blackholed → outbound DNS/HTTPS from sl-app and sl-postfix times out.
Symptom: sl-app shows unhealthy, /auth/oidc/login returns 500 with
requests.exceptions.ConnectionError: ...sso.irregularchat.com [Errno -3] Temporary failure in name resolution. Authentik is reachable from the
host fine; only this docker bridge is blocked.
Fix (and persist in /etc/iptables/rules.v4):
iptables -I DOCKER-USER 6 -s 10.1.0.0/24 \ -m comment --comment "SimpleLogin sl-network" -j ACCEPT3. DOCKER-USER ACCEPT for inbound :25 to .204
Section titled “3. DOCKER-USER ACCEPT for inbound :25 to .204”docker run will publish the listener but conntrack-based DOCKER-USER
rules drop external traffic without an explicit ACCEPT for the public IP
- port:
iptables -I DOCKER-USER 7 -p tcp -m conntrack \ --ctorigdst 46.110.152.204 --ctorigdstport 25 \ -m comment --comment "SimpleLogin postfix :25 (public)" -j ACCEPT4. Compose port binding
Section titled “4. Compose port binding”sl-postfix: ports: - "46.110.152.204:25:25"docker compose up -d to apply (NOT restart — port changes need
container recreate; see Docker rules in repo CLAUDE.md).
5. POSTFIX_FQDN aligned with the A record
Section titled “5. POSTFIX_FQDN aligned with the A record”The HELO/EHLO banner reports POSTFIX_FQDN. Set it to the same name as
the A record so forward-FCrDNS passes at receivers:
sl-postfix: environment: POSTFIX_FQDN: mx.simplymail.orgAfter change, docker compose up -d sl-postfix to recreate.
External verification once all five are in place:
swaks --server mx.simplymail.org --port 25 \ --to <any-active-alias>@simplymail.org \ --from probe@<a-real-domain> \ --quit-after FIRST-HELO
# Expect:# 220 mx.simplymail.org ESMTP Postfix# 250-mx.simplymail.org# 250-PIPELINING# ...⚠️ Sender domain validation: sl-postfix rejects mail-from with
TLDs that don’t resolve in DNS (450 4.1.8 Domain not found). Probes
must use a real sender domain. @example.test won’t work; @gmail.com
will.
Authentik OIDC
Section titled “Authentik OIDC”OIDC client config in simplelogin.env:
URL=https://app.simplymail.orgOIDC_CLIENT_ID=<32-char-secret-from-authentik>OIDC_CLIENT_SECRET=<...>OIDC_WELL_KNOWN_URL=https://sso.irregularchat.com/application/o/simplelogin/.well-known/openid-configurationAuthentik app slug: simplelogin, provider type: OAuth2/OIDC, redirect URI:
https://app.simplymail.org/auth/oidc/callback.
For the OIDC redirect to work, sl-app must be able to reach
sso.irregularchat.com:443 — that’s why the DOCKER-USER ACCEPT in
step 2 above matters. Without it, the well-known endpoint times
out and login returns 500.
Apex vs canonical-host cookie split (2026-06-08 fix)
Section titled “Apex vs canonical-host cookie split (2026-06-08 fix)”URL=https://app.simplymail.org is the canonical host. The Flask session
cookie that holds the OIDC state value gets set on exactly that host.
If you also serve sl-app on the apex simplymail.org (tunnel ingress on
both), a user who starts /auth/oidc/login on simplymail.org gets the
state cookie on simplymail.org; Authentik then bounces them to
app.simplymail.org/auth/oidc/callback; the callback host doesn’t see the
cookie → oauthlib.MismatchingStateError 500.
Two ways to fix:
-
301-redirect the apex at the edge (recommended). No tunnel ingress on the apex; a Cloudflare Single Redirect Rule via the
http_request_dynamic_redirectruleset returnshttps://app.simplymail.org/$1for every request. Single canonical host owns sessions. Configure via CF API:Terminal window curl -X POST -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \-H "Content-Type: application/json" \"https://api.cloudflare.com/client/v4/zones/$ZONE/rulesets/phases/http_request_dynamic_redirect/entrypoint" \-d '{"rules": [{"action": "redirect","action_parameters": {"from_value": {"status_code": 301,"target_url": {"expression": "concat(\"https://app.simplymail.org\", http.request.uri.path)"},"preserve_query_string": true}},"expression": "(http.host eq \"simplymail.org\")"}]}' -
Widen the cookie domain. Set
SESSION_COOKIE_DOMAIN=.simplymail.orginsimplelogin.env, then both hosts share the cookie. Larger blast radius (every subdomain can read it) — fine if you control them all.
alternative_id MUST be set on all users
Section titled “alternative_id MUST be set on all users”Flask-Login stores session['_user_id'] = user.get_id(). get_id()
returns alternative_id (a UUID) if set, otherwise the integer
primary key. If alternative_id is NULL, the next request calls
User.get_by(alternative_id="2") which never matches — session
silently dies and the user bounces back to login.
This bites only with bulk SQL imports that bypass User.create().
After any direct-DB user creation, verify and backfill:
SELECT id, email, alternative_id FROM users WHERE alternative_id IS NULL;-- if any:UPDATE users SET alternative_id = gen_random_uuid()::textWHERE alternative_id IS NULL;Reply gating — Authorized Addresses
Section titled “Reply gating — Authorized Addresses”When the alias owner replies via a reverse alias, sl-email checks
that the SMTP envelope MAIL FROM: is one of:
- the alias-owning mailbox’s email (e.g.
issac@alfaren.xyz), or - any address in
authorized_addressfor that mailbox
Anything else gets rejected with
250 SL E214 Unauthorized for using reverse alias and the mailbox
owner gets a “spoof attempt blocked” notification with a one-click
“Allow” link.
This is anti-spoofing — without it, anyone who guessed a reverse alias could forge mail from any of your aliases.
Adding an authorized address
Section titled “Adding an authorized address”UI path: click the “Allow X” button in the notification email. Sends a verification email to the address before activating.
Operator path (skip verification):
INSERT INTO authorized_address (created_at, user_id, mailbox_id, email)VALUES (NOW(), <user_id>, <mailbox_id>, '<address>')ON CONFLICT ON CONSTRAINT uq_authorize_address DO NOTHING;Then restart sl-email — it caches mailbox/authorized-address
lookups in memory:
docker restart sl-emailSchema reminder:
authorized_address(id, created_at, updated_at, user_id, mailbox_id, email)unique (mailbox_id, email)No verified column — verification happens in the app layer, so DB
insert bypasses it. Fine for operator backfill of addresses you
obviously own.
Apple Mail “From: picker” trap
Section titled “Apple Mail “From: picker” trap”Apple Mail shows every identity in the account. When replying to an
SL-forwarded message it defaults to the mailbox that received the
forward (e.g. issac@alfaren.xyz). If the user picks a different
“From:” — like admin@irregularchat.com — the reply gets rejected
unless that address is in authorized_address.
Add each Apple Mail identity that should send-as your aliases once to avoid the gate. Common candidates: Gmail identity, Workspace identity, additional personal domains.
In-network delivery to Stalwart (skip Brevo for hosted domains)
Section titled “In-network delivery to Stalwart (skip Brevo for hosted domains)”New in 2026-06-09. For recipient domains hosted on our own
Stalwart MX (irregulars.io), sl-postfix delivers
direct over the docker hairpin instead of looping the message through
Brevo. Cuts a third party out of the metadata path and saves Brevo quota.
Why route override instead of merging postfix instances
Section titled “Why route override instead of merging postfix instances”Most online guides (e.g.
simple-login/app#765,
docker-mailserver#2380)
merge SL into one big postfix — install postfix-pgsql into
docker-mailserver and mount SL’s pgsql lookup files in. We deliberately
keep two independent postfix instances and bridge them with a
transport map:
| Reason | Why two instances wins |
|---|---|
| Different security domains | Stalwart holds real mailboxes; SL handles disposable aliases with public-facing rewrite. Mix the surfaces and a misconfig in one blast-radiuses the other. |
| Independent upgrade cadence | Stalwart and simplelogin/postfix ship on different schedules. Merged → one upgrade can break the other. |
| Layered IP reputation | Brevo for simplymail.org outbound, Stalwart’s own rep for irregulars.io. Merged → one shared profile. |
| Migration optionality | Drop SL → delete the container. Stalwart untouched. |
How the override works
Section titled “How the override works”sl-postfix’s transport_maps becomes a chain (first match wins):
transport_maps = pgsql:/etc/postfix/pgsql-transport-maps.cf, # SL alias domains → sl-email handler texthash:/srv/postfix-extra/transport-extra # in-network domains → Stalwart direct# relayhost = [smtp-relay.brevo.com]:587 # everything else → Brevo fallbackThe transport-extra flat file is:
irregulars.io smtp:[46.110.152.205]:25Square brackets disable MX lookup — postfix sends straight to the IP, no DNS dependency.
Wrapping the entrypoint (why upstream main.cf can’t be patched directly)
Section titled “Wrapping the entrypoint (why upstream main.cf can’t be patched directly)”The simplelogin/postfix image’s entrypoint runs generate_config.py --postfix on every container start, which regenerates main.cf from a
Jinja template. A bind-mount over main.cf would be stomped on; the
template doesn’t expose a transport_maps env hook.
The fix: a wrapper entrypoint at
selfhost/simplelogin-app/postfix-extra/entrypoint-override.sh that:
- Replicates upstream’s
_main()flow (withPOSTFIX_CUSTOM_DATA_DIRECTORYexported — see trap #2 below) - Runs
generate_config.py --postfixso the stock config is correct - Layers our override:
postconf -e "transport_maps = pgsql:..., texthash:..." - Execs
postfix start-fg
Compose mounts the wrapper + the flat file:
sl-postfix: volumes: - ./postfix-extra/entrypoint-override.sh:/usr/local/bin/entrypoint-override.sh:ro - ./postfix-extra/transport-extra:/srv/postfix-extra/transport-extra:ro command: ["/usr/local/bin/entrypoint-override.sh"]Three traps learned while building this
Section titled “Three traps learned while building this”-
hash:driver isn’t shipped.simplelogin/postfixis Alpine-based, and Alpine deprecated BerkeleyDB.postconf -mlistslmdb,texthash,inline,regexp— but nothash. Upstream tracks this at simplelogin-postfix-docker#7. The reporter triedlmdb:and hit"open database /etc/aliases.lmdb: No such file or directory"— same wall we hit on:robind-mounts.texthash:sidesteps it: reads the flat file in place, nopostmap, no compiled DB. See Postfix DATABASE_README for the texthash trade-off (“no change detection — requirespostfix reloadafter edits”). -
POSTFIX_CUSTOM_DATA_DIRECTORYmust beexported, not just assigned. Upstream’ssetup_postfix_custom_dataexports it. If your wrapper merely assigns it (bash local),python3 generate_config.py --postfixsilently fails to writepgsql-transport-maps.cf(JinjaStrictUndefinedraises inside the--postfixstep, eaten by the wrapper). Symptom: postfix start-fg spamsopen "pgsql" configuration: No such file or directoryfor every daemon process at startup, mail to*@simplymail.orgstops resolving. -
Don’t bind-mount inside
/etc/postfix/. Postfix’s startup security check whinesnot owned by rootfor every file in/etc/postfixnot owned by UID 0. The host-mounted file is owned by the host user, not container-root. Mount to/srv/postfix-extra/instead and reference with the absolute path.
Adding a domain
Section titled “Adding a domain”$EDITOR /datadrive/home/simplelogin-app/postfix-extra/transport-extradocker exec sl-postfix postfix reloadtexthash: reads the flat file directly — no postmap, no container
restart.
Verifying the route
Section titled “Verifying the route”Look for relay=46.110.152.205[...]:25 on delivery lines:
docker logs sl-postfix --since 5m 2>&1 | grep 'to=<.*@irregulars.io>'# expected: relay=46.110.152.205[46.110.152.205]:25# wrong: relay=smtp-relay.brevo.com[...]:587Population check before extending to a new domain — SL forwards to the
mailbox.email value, NOT users.email:
docker exec sl-db psql -U simplymail_user -d simplelogin -c \ "SELECT split_part(email,'@',2), COUNT(*) FROM mailbox GROUP BY 1 ORDER BY 2 DESC;"If no mailboxes target that domain, the override does nothing yet — it becomes active the first time a user adds a mailbox there.
Networking — host-level traps
Section titled “Networking — host-level traps”These bit us after the 2026-06-06 cutover and took the rest of the week to track down. Both affect mail flow but are invisible from SimpleLogin’s logs — diagnosis requires going to the host.
Multi-homed egress routing (2026-06-09 fix)
Section titled “Multi-homed egress routing (2026-06-09 fix)”The Proxmox host has two uplinks on the same physical interface vmbr0:
- residential home-LAN
192.168.1.0/24via gateway192.168.1.1 - Metronet public block
46.110.152.200/29via gateway46.110.152.201
The default route points at the home gateway. Without policy routing,
outbound traffic sourced from .204 (sl-postfix) gets sent via the home
router — which drops it because the source IP isn’t on its LAN. Inbound
SYNs to .204:25 arrive fine; our SYN-ACK never reaches the remote → SMTP
handshakes never complete → mail never gets through.
Symptom: zero RCPT TO: from external MTAs in sl-postfix logs; only
persistent scanners occasionally land. Mail accumulates as silent
failures from senders’ side (deferred and eventually bounced).
The fix is policy routing keyed on the input interface, not source IP
— the kernel chooses return routes before POSTROUTING NAT rewrites the
source. Active on the host as metronet-egress-routing.service (systemd
oneshot). Affects both br-38a5b629a7c8 (sl-network for sl-postfix) and
br-f24e52480289 (Stalwart’s docker bridge).
Sanity-check from inside sl-postfix:
# sl-postfix → Gmail outbound IP must route via Metronet (.201), not home (.1.1)docker exec sl-postfix sh -c \ 'ip route get 209.85.220.41 from 172.17.0.X iif br-38a5b629a7c8'# expected: via 46.110.152.201 ... table metronet_egressFull diagnostic + fix recipe lives in the operator’s internal
lessons-learned/proxmox-multihomed-egress-routing.md. Key idea:
the systemd unit re-reads the docker bridge IDs each boot (they
change when networks are recreated) and inserts the policy rules
fresh, so it survives docker compose down/up cycles.
CF Email Routing dormant-rule audit (2026-06-09 fix)
Section titled “CF Email Routing dormant-rule audit (2026-06-09 fix)”When migrating off Cloudflare Email Routing onto a self-hosted MX
(SL or Stalwart), disabling Email Routing at the zone level does NOT
delete forwarding rules. Dormant rules remain enabled: true in the
API and silently intercept mail from senders whose MTAs cache the old
MX. Apple iCloud caches MX for days past TTL — so even three weeks
after a cutover, real mail can still get siphoned to an old forwarding
target.
The siphoned mail we found 2026-06-09:
sac@irregulars.io → forward:issac@alfaren.xyz — still active long
after irregulars.io’s MX moved to Stalwart on .205.
Audit + purge after any MX cutover:
ZONE=<zone-id-of-affected-domain>
# List forwarding rules — anything with action 'forward:' you don't want is poisoncurl -sS -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \ "https://api.cloudflare.com/client/v4/zones/$ZONE/email/routing/rules?per_page=50" \ | jq '.result[] | {tag, enabled, matchers, actions}'
# Delete by tagcurl -sS -X DELETE -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \ "https://api.cloudflare.com/client/v4/zones/$ZONE/email/routing/rules/$TAG"Note: the Catch-all rule cannot be deleted via API — it’s a special
zone-level setting. Verify it’s enabled: false and action: drop.
SimpleLogin domains shouldn’t have Email Routing enabled at all (we use
direct MX); Stalwart-hosted domains shouldn’t either.
Operational gotchas
Section titled “Operational gotchas”sl-db recreation invalidates app connection pools
Section titled “sl-db recreation invalidates app connection pools”After docker compose up -d --force-recreate sl-db (routine postgres
point bump), sl-email’s connection pool holds stale fds pointing at
the dead container. Next message arrives → sl-postfix returns 250 to
the sender → sl-email throws
psycopg2.OperationalError: server closed the connection unexpectedly
→ mail is silently dropped.
Always restart the app containers after a db recreate:
docker restart sl-email sl-app sl-job-runnerBetter future order: stop app containers first, recreate db, start app containers — avoids the first-mail-after-restart failure.
Upgrading SL (the path that worked: v4.78.6 → v4.81.2)
Section titled “Upgrading SL (the path that worked: v4.78.6 → v4.81.2)”Same-day-tested on 2026-06-06:
# 1. Backupdocker exec sl-db pg_dump -U simplymail_user simplelogin | gzip > sl-db-$(date +%Y%m%d).sql.gzcp /datadrive/home/simplelogin-app/docker-compose.yml{,.bak.$(date +%Y%m%d_%H%M%S)}
# 2. Bump the image tag in compose, recreate the three app containerssed -i 's|app-ci:v4.78.6|app-ci:v4.81.2|g' /datadrive/home/simplelogin-app/docker-compose.ymlcd /datadrive/home/simplelogin-appdocker compose up -d --no-deps sl-app sl-email sl-job-runner
# 3. Run alembicdocker exec sl-app alembic upgrade head
# 4. Verifydocker ps --filter "name=sl-" --format "{{.Names}}\t{{.Status}}"curl -sS -o /dev/null -w "/auth/oidc/login → %{http_code}\n" http://127.0.0.1:7777/auth/oidc/loginOnly one migration applied (4a9f8c2e1b3d Add credential metadata columns to fido table). No breaking changes in the changelog between
v4.78.6 and v4.81.2 — all bug fixes and small enhancements.
End-to-end verification
Section titled “End-to-end verification”After any change to receive path:
# A. tail both handlers in backgrounddocker logs -f sl-postfix --since 5s | sed "s/^/[postfix] /" &docker logs -f sl-email --since 5s | sed "s/^/[email] /" &
# B. send a probeswaks --to <alias>@simplymail.org --from probe@<your-real-domain> \ --server mx.simplymail.org --port 25 \ --h-Subject "verify $(date +%s)"
# C. expect in sl-email log within 1s:# "New message, mail from probe@... rctp tos ['<alias>@simplymail.org']"# "Forward <Contact X> -> <Alias Y> -> <Mailbox Z>"# "Finish ... return code '250 Message accepted for delivery'"Operator commands
Section titled “Operator commands”# Containersdocker ps --filter "name=sl-" --format "{{.Names}}\t{{.Status}}"
# Per-container logsdocker logs sl-app --tail 100docker logs sl-email --tail 50docker logs sl-postfix --tail 30docker logs sl-job-runner --tail 30
# Alembic statedocker exec sl-app alembic currentdocker exec sl-app alembic heads
# Inspect aliases / mailboxes / authorized addressesdocker exec sl-db psql -U simplymail_user -d simplelogin -c " SELECT id, user_id, email FROM mailbox ORDER BY id;"docker exec sl-db psql -U simplymail_user -d simplelogin -c " SELECT id, email, enabled FROM alias ORDER BY id LIMIT 20;"docker exec sl-db psql -U simplymail_user -d simplelogin -c " SELECT id, mailbox_id, email FROM authorized_address;"
# Force outbound test (sl-postfix → Brevo)docker logs sl-postfix 2>&1 | grep 'status=sent' | tail -5
# Free disk in container volumes (rare but possible)du -sh /datadrive/home/simplelogin-app/{,uploads,db_data}Where files live (on 100.87.20.88)
Section titled “Where files live (on 100.87.20.88)”/datadrive/home/simplelogin-app/├── docker-compose.yml # service definitions├── simplelogin.env # config (DB, OIDC, RELAY_HOST, etc.)├── custom-wsgi.py # Apache/WSGI shim (legacy, mostly unused)├── alembic.ini # migration config├── app/ # app code (mounted into sl-app)└── docker-compose.yml.bak.* # version snapshotsDB data lives in a docker volume managed by sl-db.
Pending / wishlist
Section titled “Pending / wishlist”- PTR for
46.110.152.204→mx.simplymail.orgwith Metronet (the colo). Forward A is set; reverse leg pending. Without it, strict receivers (Gmail / M365 / Proofpoint) give slightly worse spam scores on direct outbound. Brevo handles outbound today so this is non-blocking — but cleaner once filed. - CAPTCHA on signup — currently disabled because we have OIDC-only signup. If we ever open registration, re-evaluate.
- PGP signing for outbound forwards — supported by SL but not configured here. Could add per the SL docs.
- Extend in-network transport to more domains. Today only
irregulars.iois intransport-extra. When SL mailboxes accumulate on other Stalwart-hosted domains, add them. Use the population query in Verifying the route before extending.
Forking this stack (what to change)
Section titled “Forking this stack (what to change)”If you’re standing up your own SimpleLogin instance using this repo as a starting point, the minimum substitutions are:
| File | Replace |
|---|---|
selfhost/simplelogin-app/.env | SL_DB_PASSWORD, BREVO_SMTP_USERNAME, BREVO_SMTP_KEY, MAIL_FQDN |
selfhost/simplelogin-app/docker-compose.yml | The public IP in the sl-postfix port binding (46.110.152.204:25:25 → yours) |
selfhost/simplelogin-app/postfix-extra/transport-extra | If you’re not running a Stalwart sibling, delete the irregulars.io line (texthash file can be empty — the override loads but matches nothing) |
| DNS | A record mx.<your-domain>, MX 10 pointing at it (unproxied), SPF including ip4:<your-public-ip> + include:sendinblue.com, DKIM CNAMEs from Brevo dashboard |
| Authentik | Provider + Application + redirect URI https://app.<your-domain>/auth/oidc/callback |
/etc/iptables/rules.v4 | The two DOCKER-USER ACCEPTs (sl-network outbound + public-IP inbound) — see Public MX binding steps 2–3 |
Optional but recommended once stable:
- Cloudflare Single Redirect Rule for apex →
app.<your-domain>(see Apex cookie split) metronet-egress-routing.service(or equivalent) if you’re multi-homed- PTR with your colo provider before flipping outbound away from Brevo
Related
Section titled “Related”- Self-Hosted Mail Server (2026) — the big-picture mail stack this fits into; Stalwart sibling, SOGo webmail, sasl-proxy
- Email Hardening — SPF / DKIM / DMARC tuning for any sender domain
References
Section titled “References”- SimpleLogin docs — official upstream
- SimpleLogin GitHub — source + changelog
- SimpleLogin self-hosting guide
- simplelogin/postfix Dockerfile — image source
- Postfix DATABASE_README — lookup-table types (texthash, lmdb, hash)
- Postfix STANDARD_CONFIGURATION_README — selective transport delivery
- In-repo:
selfhost/simplelogin-app/README.md— code-adjacent quick reference - In-repo:
selfhost/simplelogin-app/postfix-extra/— the wrapper + transport map this guide describes