Install
When to use: putting the mail server on a fresh host. Executed by: scripts/install.sh (bootstrap) → mailctl install.
Preconditions: Debian 12 / Ubuntu 22.04+ VPS with a public IPv4, root access, ≥2 GiB RAM, ≥10 GiB disk; a hostname (e.g. mail.example.com) whose A record points at the host and whose PTR record (set at the VPS provider) points back at the hostname; ports 25, 80, 110, 143, 443, 465, 587, 993, 995, 4190 and 8081 free, and not blocked by the provider (some block outbound 25 by default — ask them to open it). 110 is POP3, 143 IMAP, 4190 ManageSieve and 8081 the mail client’s plain-HTTP port, used only with tls.mode=none. 110 and 995 refuse at Dovecot once POP3 is turned off in Settings → General (or with protocols: { pop3: false } in an answers file); they stay published, so keep them free either way. Preflight checks that all eleven are free on the host (from 0.11.0; until then only 25, 80, 443, 465, 587, 993 and 995) — it cannot check that a firewall in front of the host lets them in.
IPv6 is off by default. Every port is published on IPv4 only —
PUBLISH_BINDin.env— andmailctl doctorfails if any published port answers over IPv6 while it is off: on a network without IPv6, Docker hands the containers each IPv6 connection from its own bridge address, which the stack trusts as local. To serve IPv6, install with--ipv6(or setIPV6_ENABLED=trueand runmailctl upgrade): the Compose network gets IPv6 and every port is published on it, forwarded by Docker 27 or later so each client keeps its own address (IPv6).Publish no AAAA record for the mail hostname, or for any other name pointed at this host, unless IPv6 is on and
mailctl doctor’sipv6row is ✔ — including a name a customer points here for on-demand TLS. Caddy answers ACME challenges on ports 80 and 443; an AAAA with nothing answering on IPv6 would send those challenges to a port that does not answer, and certificates would stop renewing.
-
Bootstrap (installs Docker Engine if missing, Node.js 24,
mailctl, then runs install).get.maildock.appserves this script (since 2026-09-08). It is an alias of the marketing site’s CloudFront distribution, and the site’s deploy workflow republishes the script frommailserverat the tagcontext/versions.jsonnames — so the command below always fetches the released bootstrap, neverdevelop. Image and package names are final: images from private ECR at603283648754.dkr.ecr.us-east-1.amazonaws.com/maildock/<service>, CLI from npm as@maildock/mailctl.Terminal window curl -fsSL https://get.maildock.app/install.sh | sh -s -- --hostname mail.example.comHeadless (no browser) — write an answers file first (schema: Headless install (answers file)):
Terminal window curl -fsSL https://get.maildock.app/install.sh | sh -s -- --answers /root/setup.yaml -
mailctl installruns preflight (Docker + Compose,curl/tar(an informational warning; backup and restore runtarin a container), memory, disk at the install directory and, when it is another filesystem, at Docker’s data root (docker disk), ports, outbound SMTP on :25, IPv6 exposure (a warning whenever the host has a routable IPv6 address) — or, with--ipv6,docker ipv6(an error below Docker 27), theipv6serving check and the AAAA and IPv6 PTR rows — public IP, hostname A record, PTR). Errors (✖) stop the install; warnings (⚠) are printed and continue.--skip-preflightoverrides errors.outbound smtp :25is the check most likely to decide whether this host can run the product at all. It connects to a public MX and waits for the greeting, because binding port 25 locally proves nothing about egress. GCP and Azure block it permanently; AWS, Oracle and Hetzner until you raise a support request. It is a warning, not an error, because an outbound relay is a supported alternative and is configured after install — but if you have neither, this host will accept mail and never send any.
-
The release is authenticated and verified before anything is written to the host.
LICENSE_IDis exchanged for registry credentials, thenmailctlfetcheshttps://get.maildock.app/releases/<tag>/manifest.jsonand its signature, checks the signature against a public key it ships with, and asks the registry — with aHEAD, so no layer moves — what each of the seven tags currently resolves to. Every digest must match. It prints what it checked either way. Order matters here and is deliberate: step 4’s.envgenerates this host’s secrets, so a refusal must leave the directory untouched rather than half-built.- “…in the registry is sha256:…, but the signed manifest says sha256:…” — do not work around this. The image under that tag is not the one that was released. Exit 6, nothing written, nothing pulled.
- “No signed manifest is published for
<tag>” — the release predates signing, or itssignjob failed.latestis signed since0.3.0(2026-09-26):releases/latest/is published alongside the version, and a fresh install oflatestverified end to end on a bare host. You still see this refusal for a pinned tag that predates signing —0.2.1and earlier — and for0.3.0-rc.2, whosesignjob died on Apex’s lower-caserunner.arch;--no-verifyis the only way to install one of those. - “not signed by any key this mailctl trusts” — upgrade
mailctlfirst (npm i -g @maildock/mailctl); the signing key may have been rotated past this copy. - It is skipped, and says so, for a registry that is not ours, for an install with no
LICENSE_ID, and for--no-verify.--no-pullskips it too, along with the registry login, because nothing is being fetched.
-
It writes
/opt/mailserver/.envand the compose bundle (compose/compose.yaml,compose/compose.ipv6.yaml— applied only whenIPV6_ENABLED=true— anddocker/caddy/Caddyfile) — both shipped inside themailctlpackage. The bundle runs ten services: postgres, redis, rspamd, dovecot, postfix, api, worker (scheduled deliverability checks, migrations, authentication bans and the licence heartbeat), web (the admin panel and wizard on one port, the mail client on another), caddy, cert-sync. Existing.envsecrets are preserved on re-runs.What goes into
.envis worth knowing in full, because every one of these is either a secret you must not lose or a value that changes behaviour:- Secrets, generated here and nowhere else —
DB_ADMIN_PASSWORD,DB_DAEMON_PASSWORD,JWT_SECRET,DOVECOT_MASTER_PASSWORD(mailbox filters and DMARC ingestion),SRS_SECRET,SECRET_ENCRYPTION_KEYandMIGRATION_SECRET_KEY.SECRET_ENCRYPTION_KEYis the one to back up with the database and not separately: it is the master key for the secrets the server has to read back — the customer’s AI provider key, every TOTP seed, every DKIM private key and the outbound relay password — and losing it makes those unreadable while the rest of the install keeps working, which is the failure mode that looks like corruption and is not. Backup & restore →SECRET_ENCRYPTION_KEYis part of the backup, not an afterthought says what that looks like from the outside;mailctl backupcovers.envfor exactly this reason.MIGRATION_SECRET_KEYseals a migration’s source-server credentials, and rotating it costs only that: the admin re-enters them. - Machine fingerprint —
MACHINE_FINGERPRINT, this host’s, which the licence binds to.mailctlrecomputes it at everyinstall,upgradeandrestore, andlicense transferrewrites it when this host’s has changed; do not copy it between hosts. - Licensing —
LICENSE_ID(the subscription, exchanged for registry credentials in step 3) andLICENSE_SERVER_URL(defaulting tohttps://licence.maildock.app). Neither enforces anything; see the Options below for what an emptyLICENSE_IDcosts. - Database identity —
DB_ADMIN_USER(mail),DB_NAME(mail) andDB_DAEMON_USER(mail_daemon, the role Postfix and Dovecot authenticate to Postgres as). The daemons read Postgres directly, so these are not only the API’s. - Host and behaviour —
MAIL_HOSTNAME,PUBLIC_IP,PUBLISH_BIND(0.0.0.0: the address every internet-facing port is published on; loopback is what makestls.mode: noneacceptable; a proxy of your own in front of a loopback bind must pass the browser’sHostheader through unchanged, or every sign-in is refused403 cross_origin— see security baseline),PUBLIC_IP6(this host’s IPv6 address, detected separately; empty when it has none),IPV6_ENABLED(false;--ipv6writestrue),IMAGE_REGISTRY,IMAGE_TAG,COOKIE_SECURE(true; only a plain-HTTP dev stack sets itfalse, because aSecurerefresh cookie is never returned over plain HTTP) and the standard port set. Two of those ports are published on loopback only, and say so by their own bind variables: the API’sAPI_PORTonAPI_BINDand Postgres’sDB_PORTonDB_BIND, both127.0.0.1by default incompose.yamland not written to.env. Setting either to a public address exposes the API without Caddy in front, or the database, to the network.
LICENSE_PUBLIC_KEYis deliberately not here — it ships incompose/compose.yamlas an anchored default (Licensing). - Secrets, generated here and nowhere else —
-
docker compose pull+up -d --wait; the API applies database migrations on start. -
mailctlwaits forGET /api/v1/health, then:- with
--answers:POST /api/v1/setup/answerscompletes setup (hostname → TLS → first domain → owner → render + apply) and prints the DNS records to publish. An answers file withtls.mode: noneis refused before preflight, and before anything is pulled or written, unless the install’sPUBLISH_BINDis loopback — a fresh install writes0.0.0.0, so on a new host it is always refused without the escape — because on a host that publishes the mail ports beyond loopback it means passwords in clear text on 143 and 587 (and 110 while POP3 is on) — the CERT-Bund report that found it named exactly that. Useacmeorbyo; if you know why not,--allow-plaintext-auth(orallowPlaintextAuth: trueundertlsin the file) accepts it, andmailctl doctorreports it as ✖ from then on (Headless install (answers file)); - without: prints the Setup Wizard URL (
http://<hostname>/, orhttp://<ip>/until DNS resolves — plain HTTP, because the bootstrap Caddyfile hasauto_https offuntil setup applies) — continue with First-boot setup (browser).
- with
-
Publish the DNS records (
GET /api/v1/domains/{id}/dnslists them again at any time, DKIM included;POST …/dns/verifyor the domain’s DNS & routing section checks them live). Publish two more, by hand:postmaster.<your domain>andwebmail.<your domain>, A records pointing at the host (or CNAMEs to the mail hostname). Those are the admin panel and the mail client — one host each for the whole server, derived from the mail hostname’s domain, somail.example.comgivespostmaster.example.comandwebmail.example.com. They are not in any domain’s DNS & routing section because they are not per-domain: every mailbox on this server signs in at that one webmail name, whichever of your domains it belongs to. The mail hostname goes on serving the console either way, sopostmaster.is a convenience;webmail.is where your users actually go. Withtls.mode=acmeCaddy obtains a Let’s Encrypt certificate as soon ashttps://<hostname>resolves to the host;cert-synchands it to Postfix/Dovecot within 30 s.
Verification
Section titled “Verification”mailctl doctor is all ✔; https://<hostname>/ and https://postmaster.<domain>/ both show the admin sign-in (and /api/v1/docs the API reference) over a valid certificate; https://webmail.<your domain>/ shows the mail client and not the console — if it shows the console, the stack is running a Caddy config rendered before 2026-09-12. The API re-applies the configuration at boot whenever server_config.rendered_by differs from the running build, so restarting api normally repairs it; PUT /api/v1/server/config with the body GET returns is the fallback if it does not; an IMAP client connects to <hostname>:993 and sends through <hostname>:587 with the owner mailbox credentials; a message from an external address arrives.
Rollback
Section titled “Rollback”docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env down -v removes everything (including mail). To keep data, down without -v.
Options
Section titled “Options”--dir (install directory, default $MAILSERVER_DIR, else /opt/mailserver), --hostname, --answers, --skip-preflight, --tag (image tag, default latest / $MAILSERVER_VERSION), --registry (default $MAILSERVER_REGISTRY, else 603283648754.dkr.ecr.us-east-1.amazonaws.com/maildock), --no-pull (use locally built images — development, and it skips the registry login and the release verification with it), --no-verify (pull without checking the signed manifest — needed for unsigned releases: 0.2.1 and earlier, and 0.3.0-rc.2), --license-id (subscription id, default $MAILDOCK_LICENSE_ID), --license-server (licence server base URL, default $MAILDOCK_LICENSE_SERVER), --ipv6 (serve IPv6 too: writes IPV6_ENABLED=true, see above), --allow-plaintext-auth (accept an answers file with tls.mode: none on a public bind, see with --answers above).
--license-id (or $MAILDOCK_LICENSE_ID) is the one option step 3 depends on. It is the subscription id, it is written to .env as LICENSE_ID, and it is what mailctl exchanges for registry credentials. An empty value is not an error — it is silence. registryLogin returns skipped rather than failing, so the install pulls with no credentials and the release verification is skipped with it, because verification asks the registry for digests using those credentials. A customer who follows this run-book without passing it therefore gets an install that never authenticated and never checked a signature, and the only sign of it is two lines of output saying so. So: pass it on a customer install. Omit it only when you are running self-built images, which is the case --no-pull already covers.
--license-server (or $MAILDOCK_LICENSE_SERVER) overrides the licence server base URL, which is https://licence.maildock.app by default. It exists for a self-hosted or throwaway licence server. An unreachable one is not silent: the exchange ends failed (“Could not reach the licence server at …”) and the install stops with exit 3, before anything is written. An empty --license-server is simply ignored — the .env value stands. The only way the exchange ends up skipped on this side is an empty LICENSE_SERVER_URL in .env itself, with the same silent consequence as an empty LICENSE_ID.
Exit codes: 0 installed · 2 oclif’s own — a blocking preflight check failed (--skip-preflight to override) or --answers named a file that is not a valid answers document, and the message names the offending fields, or an answers file with tls.mode: none on a non-loopback PUBLISH_BIND without --allow-plaintext-auth · 3 the licence was refused at the registry · 6 the release did not verify against its signed manifest, and nothing was written or pulled.