Files
LambdaMail/docs/DEPLOYMENT.md
T
Lucas RayanandClaude Opus 5 c0387f6961 #21: make the stack deployable, and document how
Three things stood between this and a real deployment.

A missing JWT_SECRET fell back to a constant that is published in this
repository, so any deployment that forgot the variable would have accepted
session tokens anyone could mint, including an admin one. The auth service
now refuses to start without a secret of at least 32 characters. The test
suites set it through vitest config, because ESM hoists imports and an
assignment inside a test file runs too late.

Session cookies were issued without the Secure attribute: it is driven by
NODE_ENV, which compose never set. Behind a TLS terminator that is a
downgrade waiting to happen.

Nothing created the first mailbox. A fresh install has no domain and no
account, and every route into the console needs one - so there was no
first login. There is now a create-admin command, which also provisions
the standard folders and the four system aliases. It is a deliberate
command rather than a bootstrap on boot: creating an administrator as a
side effect of starting a container is how a deleted account quietly comes
back.

Also: the production images were shipping compiled test files, since the
build compiled everything under src. A separate build config excludes
them while lint still type-checks them.

Documentation. README.md covers what this is, how it is put together, and
what is not finished - stated plainly, because a mail server that half
works loses mail. docs/DEPLOYMENT.md is the Coolify path in the order it
has to happen, starting with the three prerequisites no code can satisfy:
outbound port 25, the PTR record, and the DNSSEC delegation.
docs/OPERATIONS.md is the runbook - health states, certificates, backups,
reading a delivery failure, DKIM rotation, and an explicit list of what is
not automated. .env.production.example replaces guessing which of the dev
defaults are safe: none of them are.

docs/ was gitignored as a place for internal notes; only docs/internal/ is
now, so these guides ship with the code.

Every command the documentation tells an operator to run was executed
against a running stack: create-admin, preflight, the health endpoint, the
published mail ports, and the Secure cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:33:51 -03:00

6.9 KiB

Deploying LambdaMail on Coolify

This is the order things have to happen in. Several steps depend on DNS propagation or on your provider, so read the whole page before starting — a couple of them are worth kicking off early.

Before anything else

Three prerequisites are not automatable, and without them the deployment will come up but mail will not work. Start them now, because two involve waiting on somebody else.

1. Outbound port 25

Most providers block it by default — AWS, GCP, Azure, Oracle, Vultr, and DigitalOcean on newer accounts. Open a support ticket asking for outbound SMTP to be unblocked, and say what the host is for.

If the answer is no, LambdaMail can still send through a relay: set RELAY_HOST and friends, and the relay is added to the published SPF record automatically. Receiving is unaffected either way.

2. Reverse DNS (PTR)

The PTR for your public IPv4 must resolve to PRIMARY_MAIL_HOST, and that name must resolve back to the same address. This is called FCrDNS, and Gmail and Outlook will refuse or spam-folder mail without it.

The PTR is set by whoever owns the IP block — your VPS provider, not Cloudflare. It is usually a field in the server's control panel.

dig -x 203.0.113.10 +short        # must print mail.example.com.
dig mail.example.com +short       # must print 203.0.113.10

3. DNSSEC — only if you want DANE

Signing the zone is a prerequisite for DANE. It is enabled in the Cloudflare dashboard and needs a DS record published at your registrar, which is manual.

Skip this for a first deployment. DANE stays off under TLS_MODE=traefik anyway, and turning it on without understanding the rollover is the single most effective way to make every validating MTA reject your mail permanently.

IPv6

Either configure it properly — AAAA record, matching PTR, and covered by SPF — or leave PUBLIC_IPV6 empty. Half-configured IPv6 is a common cause of rejection, and empty is a safe answer.


Step 1 — Point DNS at the host

Two records must exist before the first deploy, because Coolify's proxy needs them to obtain certificates:

Type Name Value
A mail your public IPv4, DNS only, not proxied
A/CNAME mta-sts the same host (may be proxied)

The mail record must not be behind Cloudflare's proxy. SMTP is not HTTP; proxying it breaks the mail ports and hides your real address from the certificate challenge.

The rest of the 13 records are created by the service once you give it a Cloudflare token, or you can add them by hand later.

Step 2 — Create the application in Coolify

  1. New Resource → Docker Compose, pointed at this repository.
  2. Coolify will read docker-compose.yaml. Leave the compose file alone; all configuration is environment.
  3. Under Environment Variables, paste the contents of .env.production.example and replace every value marked CHANGE.

Generate each secret separately:

openssl rand -base64 36

LAMBDAMAIL_MASTER_KEY deserves particular care. It encrypts every DKIM private key, TOTP secret and provider token in the database. Store a copy somewhere other than the database backup. If you lose it, those secrets are unrecoverable and you will be reissuing DKIM keys and re-enrolling every user's two-factor.

Ports

Coolify does not map mail ports for you. In the service settings, make sure the host publishes:

25, 465, 587      SMTP
143, 993          IMAP
110, 995          POP3
4190              ManageSieve

These are already declared in docker-compose.yaml; what matters is that the VPS firewall allows them inbound.

The proxy network

The compose file joins Coolify's proxy network, named coolify by default. If yours differs, set COOLIFY_PROXY_NETWORK.

Step 3 — Deploy and check the preflight

Deploy, then run the diagnostics from the host:

docker compose run --rm --entrypoint /app/lambdamail-protocols protocols preflight

It reports outbound port 25, PTR and FCrDNS, whether your IP is on Spamhaus, SpamCop or SORBS, the Cloudflare token's scope, and whether a certificate exists for the mail host. Fix anything it flags before going further — particularly an RBL listing, which will not fix itself.

Step 4 — Create the first administrator

Nothing else creates a mailbox, so without this there is no way into the console:

make create-admin EMAIL=you@example.com PASSWORD='a long passphrase'

This creates the domain if needed, the mailbox as SUPER_ADMIN, its standard folders, and the four system aliases (postmaster@, abuse@, dmarc@, tlsrpt@) that the published DNS records point at.

Then:

  1. Sign in to the webmail at https://<WEBMAIL_HOST>/user/login.
  2. Go to Settings and enrol a second factor. The admin console requires one and will refuse to open until it exists.
  3. Sign in to https://<WEBMAIL_HOST>/admin/login.

Step 5 — DNS records

With a Cloudflare token set, the service reconciles the zone on start and every six hours after that. Watch it:

docker compose logs -f protocols | grep "DNS reconcile"

It is idempotent, and it never deletes records it did not create — anything unexpected is reported as a conflict rather than overwritten.

Without a token, add the records by hand. The admin console's Domains page lists what is expected.

Step 6 — Verify

Send mail to a Gmail address and check the headers show spf=pass, dkim=pass, dmarc=pass. Then run a round trip through mail-tester.com and read every point it deducts.

A first deployment will usually be short a few points until the DNS records have propagated and the IP has some sending history.


Upgrading

Images are published to GHCR for linux/amd64 and linux/arm64 on every merge to main.

docker compose pull
docker compose up -d

Migrations run automatically on start, before the services that depend on them. To pin a version, set IMAGE_TAG to a release version or a sha- tag rather than latest.

Troubleshooting

The web UI is up but nothing can log in. The auth service refuses to start without a JWT_SECRET of at least 32 characters. docker compose logs auth will say so.

Mail is accepted but never arrives. Check docker compose logs -f protocols for the delivery worker. A 4.7.5 deferral means the destination published a TLS policy that could not be satisfied — that is deliberate; the message is retried rather than sent in the clear.

/health returns X-LambdaMail-Health: degraded. The stack is running but a certificate needs attention: usually no certificate exists for PRIMARY_MAIL_HOST because no router was declared for it, or one is expiring within 24 hours. The body says which.

Everything is fine but Gmail still spam-folders you. That is reputation, not configuration. A new IP has none. Send low volumes to engaged recipients first and watch the DMARC reports in the admin console.