2026-10-08_mail-front-proxy-oci-micro
Mail Front Proxy — public MX front, Stalwart hidden behind headscale
(originally scoped to the Oracle micro; after the PTR finding the recommended front is a PTR-capable tiny VPS — see §3a/§5. Architecture is identical either way.)
Date: 2026-10-08 (execution day) · Author: Hermes (planned 2026-10-07) Status: PLAN — decisions confirmed (§0); front-host choice revised by the PTR finding (~§0.6). Goal (Kris): host email for three domains for ≤12 months such that outsiders cannot correlate the mail-server IP with the IP used for other projects. Outbound is already masked (hetzner1 → Plunk → SES, recipients see Plunk/SES, never hetzner1). The remaining leak is the inbound MX IP, which today points at hetzner1. Fix: put a cheap public front host in front as the only public face; the real Stalwart MTA stays on hetzner1, reachable only over headscale.
0. CONFIRMED (Kris, 2026-10-08) — execute on this basis
- Gateway design = L4 TCP proxy on the micro (HAProxy
mode tcp), forwarding :25 over headscale to Stalwart on hetzner1. Micro never originates SMTP (OCI's blocked outbound-25 is irrelevant). No Postfix MTA on the micro. - The 3 domains = MIGRATIONS from other providers. All three zones' DNS moves to Cloudflare. Stalwart on hetzner1 must host the 3 domains (users + DKIM + DKIM keys). The plan must therefore include a mail migration (existing messages/users), not just DNS.
- Ports = inbound :25 only. No 465/587/993/143 pass-through. (Optional later; not now.)
- PTR = needed, and covered — see §3a. Short answer: needed even for inbound-only (many receivers reject/enumerate a MX with no/ugly PTR and score it as spam), the single PTR for the one front IP → one canonical FQDN (not per-domain); it is a manual Oracle support request and Oracle validates the forward A record first. Oracle refusal is NOT a blocker — the design pivots (§3a "if Oracle refuses").
- ✅ Front domain =
mail2.my(Kris, 2026-10-08 — deliberate, decoupled from the Wine2You brand domain). Canonical mail FQDNmail2.my; PTR + all three domains' MX point at it. Zone already on Cloudflare (Lumlys account) →A mail2.my → 147.93.120.158(proxy OFF) created + verified (§3b). PTR →mail2.mypending (Kris, hPanel). - ⚠️ PTR BLOCKER (discovered 2026-10-08) — the Always-Free micro CANNOT get a PTR. Oracle Free Tier FAQ: "Customers using only Always Free resources are not eligible for Oracle Support… not eligible to raise a service request." A PTR is a service request. So a purely-Always-Free tenancy has no support entitlement to file it at all (independent community reports confirm: "impossible for free-tier/always-free customers to request PTR entries"). ⇒ The micro is NOT the right front. OCI stays viable only via a PAYG upgrade.
- ✅ FRONT HOST DECIDED (Kris, 2026-10-08) — Hostinger VPS
srv715158replaces the micro.147.93.120.158/srv715158.hstgr.cloud, Ubuntu 24.04, 1 vCPU / 3.9 GB / 48 GB, Docker 29.8.0, root SSH (default key works). Verified live 2026-10-08 (§2a). Clears the PTR blocker: Hostinger hPanel allows a self-service custom PTR (current PTR is the genericsrv715158.hstgr.cloud, forward-confirmed). Outbound :25 is OPEN on this box (unlike OCI/Hetzner). NB: the box also runs the Artland crawler stack (Postgres 5432, Redis 6379) — do not disturb;5432 is exposed publicly by Docker[FIXED 2026-10-08, see §0.9]. - ✅ MTA vs L4 proxy — DECIDED: stay with the L4 TCP proxy, NOT a full MTA. Outbound :25 is open on this box, but that does not justify a full MTA; it would actively work against the concealment goal. Full reasoning in §3c. (The open :25 is still a nice-to-have: it keeps the door open for a full MTA later if requirements change.)
- ✅ PTR/FQDN =
mail2.my(Kris owns the domain; NS at joker.com → move zone to Cloudflare first, §3b). Hostinger hPanel PTR →mail2.my. - ✅ SECURITY FIX APPLIED 2026-10-08 (§2b): Artland's Postgres 5432 + Redis 6379 were
publicly reachable on
147.93.120.158(Docker publishes on0.0.0.0, bypassing ufw). Fixed: bound both to127.0.0.1in the compose file and added a persistentDOCKER-USERguard in/etc/ufw/after.rules. Verified closed from outside; containers healthy.
1. Target topology
Internet (MX lookup)
│
<domain1..3> MX 10 mail2.my
│
┌───────────▼──────────────────────┐
│ Hostinger VPS srv715158 │ ← ONLY public face of mail
│ 147.93.120.158 :25 │ (custom PTR set in hPanel)
│ HAProxy mode tcp │
└───────────┬──────────────────────┘
│ headscale (WireGuard)
│ (VPS tailnet 100.64.0.x) → 100.64.0.1:25
┌───────────▼──────────────────────┐
│ hetzner1 Stalwart :25 │ ← real MTA, NO public :25 exposure for these 3
│ 100.64.0.1 │ (hetzner1 keeps its own MX for existing domains)
└───────────────────────────────────┘
Outbound unchanged: Stalwart → Plunk → SES. Recipients see Plunk/SES. No origin leak.
2a. Front host — verified live 2026-10-08 (Hostinger srv715158)
| Item | Value |
|---|---|
| Host / IP | srv715158.hstgr.cloud / 147.93.120.158 |
| OS / init | Ubuntu 24.04.4 LTS, systemd |
| Size | 1 vCPU / 3.9 GB RAM / 48 GB disk (37 GB free) — ample for a :25 TCP proxy |
| Docker | 29.8.0 present |
| SSH | root@147.93.120.158 works with default key (BatchMode OK) |
| PTR now | srv715158.hstgr.cloud (generic, forward-confirmed) → change to mail2.my in hPanel |
| Outbound :25 | OPEN (PORT25_OUTBOUND_OK) — better than OCI/Hetzner (irrelevant to L4, but a bonus) |
| Inbound firewall | ufw active, default-deny; open = 22, 80, 443 (Nginx Full), 8096. :25 will be added. |
| Other tenants | ⚠️ Artland crawler stack: gami_postgres_buffer (5432) + gami_redis_queue (6379), compose /root/artland3/gami-crawler-core/. Do not disturb. |
⚠️ Security note (pre-existing, not mail-related): Docker publishes 5432/6379 on
0.0.0.0and Docker's iptables rules bypass ufw — so Postgres 5432 is reachable from the internet even though ufw shows no rule for it. Recommend binding those to127.0.0.1:in the compose file (or a ufw-DOCKER-USER rule) — flagging for a separate fix; don't bundle it here.
2b. Security fix applied 2026-10-08 (Artland Postgres/Redis exposure)
Finding (verified from the Mac): both 147.93.120.158:5432 (Postgres) and :6379 (Redis)
answered from the public internet — nc -z succeeded on both. Root cause: the compose file
published '5432:5432' / '6379:6379' (all interfaces) and Docker's iptables rules bypass
ufw, so the ufw default-deny never applied to them.
Fix (applied, verified):
- Backed up the compose file (
docker-compose.yml.bak-20261008T061049Z). - Rebound both to loopback:
"127.0.0.1:5432:5432"/"127.0.0.1:6379:6379"; recreated the two containers (docker compose up -d). - Added a persistent
DOCKER-USERguard (Docker's documented ufw pattern) inside/etc/ufw/after.rules—-i eth0 -p tcp --dport 5432/6379 -j DROP— so the ports stay shut even if a future container re-publishes them. Backup:after.rules.bak-20261008T061123Z. (Also corrected/etc/ufw/ufw.conftoENABLED=yes, which a failed reload had left out of sync with the running firewall.) - Verified from outside:
5432→ CLOSED,6379→ CLOSED,22→ still OPEN. Containers healthy:pg_isready= accepting connections,redis-cli ping= PONG.
Non-goal: unrelated to mail; done as a standalone security fix per Kris #2. The mail work
(:25 proxy) is separate and does not touch this stack.
3b. mail2.my DNS — ✅ DONE 2026-10-08
Correction to the original assumption: mail2.my is already on Cloudflare NS
(lovisa.ns.cloudflare.com, margo.ns.cloudflare.com, status active) in the Lumlys CF account
— the registrar move was already completed, so no joker.com interaction was needed.
Applied (verified):
- Deleted the 3 parked proxied A records (
mail2.my,www.mail2.my,*.mail2.my→194.245.148.189) — they were a placeholder site. Rollback data saved (/tmp/.mail2-records-before.txt). - Created
A mail2.my → 147.93.120.158, proxy OFF (grey cloud), TTL 300. - Verified public resolution:
dig @1.1.1.1 mail2.my→147.93.120.158. - Account/token used:
Cloudflare Lumlys (API Token)(1P, itemhohwstcakdvtw4ih6hkmmeins4, custom field API Token — not the genericcredentialfield). Token can read + write DNS in themail2.myzone.
Still to do (Kris, hPanel): set the PTR 147.93.120.158 → mail2.my (Hostinger → VPS
srv715158 → Network/PTR). PTR lives at the IP provider, not CF. Forward (A mail2.my) is
already in place, so this completes forward-confirmed rDNS.
Then: the 3 migrating domains' MX → 10 mail2.my.
3c. MTA vs L4 proxy — why we stay L4 (decision record)
Question (Kris): outbound :25 is open on this box — should we switch to a full MTA?
Answer: No. Stay with the L4 TCP proxy. Reasons, in order of weight:
- A full MTA re-introduces the exact leak we're hiding. The whole point is that the origin
(hetzner1 / your other projects' IP) must not be correlatable. An L4 proxy passes the SMTP
stream through untouched — Stalwart on hetzner1 stays the only MTA, and its
Received:header (with the front's hostname) is the first hop. A second MTA on this box becomes the first hop, then hands off to Stalwart; now you have two MTAs, two queues, two sets of headers, two DKIM-signing points to keep clean — strictly more places for the origin to leak and for DKIM/alignment to break. - It buys nothing you need. The one thing a full MTA adds is store-and-forward (mail queued if hetzner1 is down). That's a resilience nicety, not a requirement; for a ≤12-month, low-volume mail front it isn't worth the complexity. Your outbound already goes hetzner1→Plunk→SES, so a local MTA here wouldn't even carry outbound.
- Deliverability is worse, not better. A fresh MTA on a shared Hostinger IP inherits the IP's reputation either way; but a transparent L4 pass-through means receivers see Stalwart (an established, correctly-configured MTA) doing the SMTP — no new reputation surface. A new MTA here would have to earn reputation and would need its own SPF/DKIM/DMARC/PTR story.
- Operational surface. L4 = HAProxy
mode tcp, ~10 lines, no queue, no mail spool, no bounces to manage, no user/mailbox config on the front. A full MTA = Postfix/Exim + queue + logs + spam handling + another DKIM key set. For a proxy whose only job is "accept :25, forward over the tunnel," that's all downside.
When a full MTA would be right: if you ever want this box to (a) queue mail during hetzner1 downtime, (b) run mailboxes locally, or (c) send mail from its own IP. None apply now. The open outbound :25 just means the option stays open — we don't have to take it.
Net: keep L4. The open port is a bonus, not a trigger.
2. Live facts already verified (2026-10-07)
| Item | Value |
|---|---|
| Oracle micro | instance-20261005-1925, VM.Standard.E2.1.Micro, 1 OCPU/1 GB, AD-1 eu-zurich-1 |
| Micro private IP | 10.0.0.62 (VCN digid1) — no public IP yet, not on tailnet |
| Oracle A1 (separate) | wine2you-dev-zrh, tailnet 100.64.0.6, tag:wine2you — not used for mail |
| Stalwart | Docker container stalwart on hetzner1, binds 0.0.0.0:25 + 143/465/587/993 |
| hetzner1 tailnet | 100.64.0.1 (tailscale 1.102.4) |
| hetzner1 ufw | ALLOW 100.64.0.0/10 (all ports) + 25/tcp ALLOW Anywhere — no firewall change needed |
| headscale | systemd headscale.service, config /opt/headscale/config/config.yaml, policy /opt/headscale/config/policy.hujson (mode: database) |
| Existing tags | tag:mail, tag:wine2you, tag:cb (owner kriswork@) — reuse tag:mail |
| DNS | Cloudflare holds the zones (all 3 target domains' zones move to CF) |
| The 3 domains | MIGRATIONS from other providers — need Stalwart registration + user/mailbox + DKIM + message migration, then MX repoint |
| OCI outbound 25 | blocked (irrelevant — micro never originates SMTP in the L4 design) |
3a. PTR — the whole answer (needed? what record? multi-domain? can Oracle do it?)
Q: Is PTR needed if the front only receives (inbound-only)? — YES. Not for sending, but for the inbound path the receiving world still checks the connecting IP's rDNS:
- FCrDNS / RBL / reputation gates. Receiving MTAs and every RBL do a forward-confirmed
reverse DNS check on the peer IP before accepting. A missing or generic PTR
(
140-238-x-x.oraclecloud.com) raises the spam score and can cause outright rejection. The peer is the front, so the front's PTR is what's evaluated. - Header hygiene. Without a clean PTR the
Received:chain shows a bare IP — which also weakens the "clean front / hidden origin" story.
Q: With one front proxying MULTIPLE domains — is ONE PTR acceptable? — YES, it's the correct pattern, and per-domain is impossible anyway. PTR is 1:1 with the IP, not with the domain: Oracle: "You can only have a 1:1 connection between the PTR and the IP address. Associating multiple PTRs for the same IP address to multiple hostnames isn't supported." So there is one PTR, pointing at one FQDN, regardless of how many MX/domains feed it. Layout:
PTR FRONT_IP → mail2.my (the ONE canonical mail host)
A mail2.my → FRONT_IP (must exist BEFORE the PTR request)
MX <domain1> 10 mail2.my
MX <domain2> 10 mail2.my
MX <domain3> 10 mail2.my
The three domains differ only in MX (all → the same front FQDN → same IP → one PTR). This is exactly how large providers work (one MTA name shared by many domains). A per-domain PTR is not possible and not needed; one PTR is both acceptable and correct.
Q: Will Oracle actually grant it? — NO, not on an Always-Free tenancy. This is a hard blocker.
- Oracle Free Tier FAQ: "Customers using only Always Free resources are not eligible for Oracle Support… you will not be eligible to raise a service request." A PTR is created only via a service request. ⇒ a purely Always-Free tenancy has no support entitlement to file it — not "they refuse", but "there is no channel".
- Oracle community: "it is currently impossible for free-tier / always-free customers to request PTR entries for the IP (v4 and v6) assigned to their OCI VMs."
- To make OCI work you'd upgrade the tenancy to Pay-As-You-Go (your Always-Free resources stay free; the account gains support entitlement, so the PTR request becomes fileable) — then still wait on a human round-trip.
Therefore the recommended change (Kris #3 approved the fallback trigger): put the front on a box with a self-service PTR field, not the OCI micro. Concretely:
- €0.60 Hostinger VPS, or any tiny Hetzner/Contabo instance — you set the PTR yourself in the provider panel, instantly, no support queue. Cost ~CHF 0.60–4/mo.
- Everything else in this plan is unchanged: same L4 HAProxy proxy, same headscale hop to Stalwart on hetzner1, same DNS/MX/DKIM model, same concealment result.
- The micro is only preferred when the OCI tenancy is upgraded to PAYG and you accept the support round-trip. Otherwise the small VPS wins outright.
Net: one PTR, mail2.my, is correct for all three domains; but the host that can
actually get a PTR is a PTR-capable VPS, not the Always-Free micro.
Phase A — Give the front host its public identity (Hostinger hPanel, ~15 min)
- Set the custom PTR in hPanel: VPS
srv715158→ Network / PTR (rDNS) → set147.93.120.158→mail2.my. (Self-service; that's why this box replaces the micro. Allow a few hours for propagation.) Do this early — it's the step the OCI micro couldn't do. - Create the forward record first (Cloudflare):
A mail2.my → 147.93.120.158, proxy OFF (grey cloud). Required for forward-confirmed rDNS. - Open inbound :25 in ufw on the VPS:
ufw allow 25/tcp. (Also ensure Hostinger's upstream firewall/panel doesn't block 25 — VPS plans typically don't.) - Confirm the box:
ssh root@147.93.120.158works with your default key (verified).MAILGW_IP = 147.93.120.158.
Phase B — Join the VPS to headscale (~15 min on the VPS)
- Install tailscale on the VPS, join with the mail tag and the two guardrails that matter:
tailscale up --login-server https://headscale.digid.co \ --advertise-tags=tag:mail --accept-dns=false --accept-routes=false--accept-dns=falseis mandatory — otherwise tailscaled rewrites the resolver and the gateway's own MX/RBL/PTR lookups change silently.- Do not advertise routes or an exit node.
- Note the VPS's tailnet IP (call it
100.64.0.x,MAILGW_TS). Set WG MTU ≤ 1420 on the VPS'stailscale0if large-attachment tests stall.
Phase C — headscale ACL (on hetzner1, ~5 min) — the silent-timeout trap
- Edit
/opt/headscale/config/policy.hujson— add an accept rule:
⚠️ A missing ACL entry fails SILENTLY (hang, no RST). This is the #1 thing that will look like "Stalwart is down" tomorrow if forgotten.{ "action": "accept", "src": ["tag:mail"], "dst": ["100.64.0.1:25"] } - Back up first (
cp policy.hujson policy.hujson.bak-mailgw-$(date -u +%Y%m%dT%H%M%SZ)), then reload headscale (systemctl reload headscale/ restart). Verify withheadscale nodes list.
Phase D — L4 proxy on the front host (~15 min)
- Install HAProxy. Minimal frontend/backend:
(Equivalent alternative: socatfrontend smtp_in bind *:25 mode tcp default_backend stalwart backend stalwart mode tcp server stalwart 100.64.0.1:25 checkTCP-LISTEN:25,fork,reuseaddr TCP:100.64.0.1:25, or iptables DNAT to100.64.0.1. HAProxy is preferred: health-checked, restartable, visible logs.)- Also install
tailscale+haproxyas services; enable on boot. - Do not run a full Postfix here — that's the rejected design.
- Also install
Phase E — Stalwart accepts the relayed source (hetzner1, ~10 min)
- Harden Stalwart so the tunnel source is trusted for inbound delivery only, still
auth-only for submission — never add
100.64.0.0/10to a relay-permit list (open-relay risk; classic misread of 100.64/10 as "private").- Since the front proxies at L4, the SMTP session to Stalwart arrives from the front's
tailnet IP — Stalwart sees a normal inbound MTA connection on :25, which is what its MX
listener already expects. No relay config needed in the L4 design (this is precisely
why L4 is simpler than the MTA design). Ver Stalwart's
IncomingHostname/ banner if you want the EHLO name to read as the gateway.
- Since the front proxies at L4, the SMTP session to Stalwart arrives from the front's
tailnet IP — Stalwart sees a normal inbound MTA connection on :25, which is what its MX
listener already expects. No relay config needed in the L4 design (this is precisely
why L4 is simpler than the MTA design). Ver Stalwart's
- Header hygiene (the actual concealment): ensure no component appends a
Received:line exposing100.64.0.1or hetzner1's public hostname. In the L4 design Stalwart is the first MTA, so itsReceived:header uses its ownhostname. Set Stalwart's advertised hostname to the front domain (e.g.mail.example.tld), nothetzner1/digid.co.
Phase F — the 3 domains: migrate mail + repoint DNS (~per domain)
F1 — Prepare the target (hetzner1 Stalwart), per domain:
12. Register the domain in Stalwart, create mailboxes/users, generate DKIM keys, and record
the DKIM selector TXT value. This must be done before MX cutover so mail lands somewhere
the moment DNS flips.
13. Migrate existing mail from the current provider: use imapsync (or the provider's export)
to pull each mailbox's messages/ folders into the new Stalwart mailbox. Do a final delta
sync at cutover so nothing is lost. (This is the step that makes these migrations, not
fresh setups.)
F2 — Cloudflare DNS (per domain):
14. For each domain: MX → 10 mail2.my (the shared front FQDN), where
mail2.my is a Cloudflare A record → MAILGW_IP, proxy OFF (grey cloud) —
mail must not go through Cloudflare's web proxy. (One front FQDN serves all 3 domains; only
the MX target differs per zone.)
15. PTR: MAILGW_IP (147.93.120.158) → one FQDN mail2.my (§3a). Set in
Hostinger hPanel → Network/PTR after the forward A record exists. Not per-domain.
16. SPF/DKIM/DMARC per domain, aligned with the sending path that actually signs
(Stalwart→Plunk→SES). Publish the Stalwart DKIM selector TXT from F1; SPF must cover the
sender (Plunk/SES), NOT MAILGW_IP (the front receives only, it never sends).
17. Outbound: unchanged — Stalwart→Plunk→SES; recipients see Plunk/SES, never hetzner1 or the
micro.
Cutover order (per domain): Stalwart ready (F1) → DKIM/SPF records live → lower MX TTL a day
ahead → flip MX to mail2.my → final imapsync delta → monitor → raise TTL. Keep the old
provider's MX/records for 24–48 h as rollback.
Phase G — Verification (acceptance)
dig MX <domain>→mail2.my→MAILGW_IP.- Inbound: send from an external address (e.g.
kriswork@pm.me) touser@<domain>; confirm arrival in Stalwart and that theReceived:headers show onlyMAILGW_IP/mail2.my— no100.64.0.1, no hetzner1 public host. - Large attachment (≥5 MB) through the tunnel — proves MTU.
dig -x MAILGW_IP→ the front FQDN (PTR done).- Migrated mail: confirm old folders/messages present post-cutover (imapsync delta ran).
- Cross-check the concealment goal:
dig -x hetzner1_public_ip,nslookup mail2.my, and read a received message'sReceived:chain — none may reveal hetzner1 for these 3 domains. - Non-regression: hetzner1's existing domains (digid.co etc.) still receive/send unchanged.
4. Guardrails (from prior mail-mesh incidents — do not skip)
- Never add
100.64.0.0/10to Stalwart trusted/relay-permit (open relay). --accept-dns=falseon the front host (resolver swap breaks deliverability checks).- headscale ACL entry is mandatory or :25 hangs silently.
- WG MTU ≤ 1420 (large-mail/TLS stall).
- Cloudflare MX/A records proxy OFF.
- Stalwart advertised hostname = front domain (no origin leak in
Received:). - OCI: (was the micro) — irrelevant now; the front is the Hostinger VPS and it never originates SMTP in the L4 design. Egress stays hetzner1→Plunk→SES.
5. Front host — DECIDED: Hostinger VPS srv715158 (2026-10-08)
- Chosen:
147.93.120.158/srv715158.hstgr.cloud— a VPS you already own and can use immediately. Verified live (§2a): Ubuntu 24.04, 1 vCPU / 3.9 GB, Docker, root SSH, outbound :25 open, and — the deciding factor — Hostinger hPanel gives self-service PTR, which the OCI Always-Free micro could not. - Rejected: the OCI micro — no support entitlement on an Always-Free tenancy ⇒ no PTR is obtainable at all (§3a). It stays idle (CHF 0); OCI only becomes viable via a PAYG upgrade.
- Cost: the VPS is already paid for; no new spend.
- Caveat — shared box: it also runs the Artland crawler stack (Postgres 5432 / Redis 6379). Adding a :25 TCP proxy + one ufw rule doesn't disturb them, but keep it in mind (and see the §2a security note about 5432 being publicly exposed — a separate fix).
- Remaining caveat: concealment is best-effort. With PTR/A/SPF/DKIM all on the front and
Received:hygiene, casual and automated correlation to your other-project IP is defeated — the stated objective.
6. Rollback
- DNS (per domain): revert MX + keep the old provider's records (held 24–48 h after cutover); flip MX back and mail resumes at the old provider. Prepared values in the change record.
- Front host:
systemctl stop haproxy; headscale ACL rule removable;ufw delete allow 25/tcp; restore the PTR tosrv715158.hstgr.cloud(optional). The VPS otherwise returns to as-was. - Stalwart: no relay-config change was made in the L4 design, so rollback is DNS-only.
- Migration: the old provider remains the authoritative source until you delete it — do not cancel the old mail hosting until a full week of clean inbound/outbound is confirmed; that box is the rollback for migrated messages.