Skip to content

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_BIND in .env — and mailctl doctor fails 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 set IPV6_ENABLED=true and run mailctl 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’s ipv6 row 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.

  1. Bootstrap (installs Docker Engine if missing, Node.js 24, mailctl, then runs install).

    get.maildock.app serves 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 from mailserver at the tag context/versions.json names — so the command below always fetches the released bootstrap, never develop. Image and package names are final: images from private ECR at 603283648754.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.com

    Headless (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
  2. mailctl install runs preflight (Docker + Compose, curl/tar (an informational warning; backup and restore run tar in 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), the ipv6 serving check and the AAAA and IPv6 PTR rows — public IP, hostname A record, PTR). Errors (✖) stop the install; warnings (⚠) are printed and continue. --skip-preflight overrides errors.

    • outbound smtp :25 is 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.
  3. The release is authenticated and verified before anything is written to the host. LICENSE_ID is exchanged for registry credentials, then mailctl fetches https://get.maildock.app/releases/<tag>/manifest.json and its signature, checks the signature against a public key it ships with, and asks the registry — with a HEAD, 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 .env generates 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 its sign job failed. latest is signed since 0.3.0 (2026-09-26): releases/latest/ is published alongside the version, and a fresh install of latest verified end to end on a bare host. You still see this refusal for a pinned tag that predates signing — 0.2.1 and earlier — and for 0.3.0-rc.2, whose sign job died on Apex’s lower-case runner.arch; --no-verify is the only way to install one of those.
    • “not signed by any key this mailctl trusts” — upgrade mailctl first (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-pull skips it too, along with the registry login, because nothing is being fetched.
  4. It writes /opt/mailserver/.env and the compose bundle (compose/compose.yaml, compose/compose.ipv6.yaml — applied only when IPV6_ENABLED=true — and docker/caddy/Caddyfile) — both shipped inside the mailctl package. 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 .env secrets are preserved on re-runs.

    What goes into .env is 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_KEY and MIGRATION_SECRET_KEY. SECRET_ENCRYPTION_KEY is 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_KEY is part of the backup, not an afterthought says what that looks like from the outside; mailctl backup covers .env for exactly this reason. MIGRATION_SECRET_KEY seals 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. mailctl recomputes it at every install, upgrade and restore, and license transfer rewrites 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) and LICENSE_SERVER_URL (defaulting to https://licence.maildock.app). Neither enforces anything; see the Options below for what an empty LICENSE_ID costs.
    • Database identity — DB_ADMIN_USER (mail), DB_NAME (mail) and DB_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 makes tls.mode: none acceptable; a proxy of your own in front of a loopback bind must pass the browser’s Host header through unchanged, or every sign-in is refused 403 cross_origin — see security baseline), PUBLIC_IP6 (this host’s IPv6 address, detected separately; empty when it has none), IPV6_ENABLED (false; --ipv6 writes true), IMAGE_REGISTRY, IMAGE_TAG, COOKIE_SECURE (true; only a plain-HTTP dev stack sets it false, because a Secure refresh 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’s API_PORT on API_BIND and Postgres’s DB_PORT on DB_BIND, both 127.0.0.1 by default in compose.yaml and 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_KEY is deliberately not here — it ships in compose/compose.yaml as an anchored default (Licensing).

  5. docker compose pull + up -d --wait; the API applies database migrations on start.

  6. mailctl waits for GET /api/v1/health, then:

    • with --answers: POST /api/v1/setup/answers completes setup (hostname → TLS → first domain → owner → render + apply) and prints the DNS records to publish. An answers file with tls.mode: none is refused before preflight, and before anything is pulled or written, unless the install’s PUBLISH_BIND is loopback — a fresh install writes 0.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. Use acme or byo; if you know why not, --allow-plaintext-auth (or allowPlaintextAuth: true under tls in the file) accepts it, and mailctl doctor reports it as ✖ from then on (Headless install (answers file));
    • without: prints the Setup Wizard URL (http://<hostname>/, or http://<ip>/ until DNS resolves — plain HTTP, because the bootstrap Caddyfile has auto_https off until setup applies) — continue with First-boot setup (browser).
  7. Publish the DNS records (GET /api/v1/domains/{id}/dns lists them again at any time, DKIM included; POST …/dns/verify or the domain’s DNS & routing section checks them live). Publish two more, by hand: postmaster.<your domain> and webmail.<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, so mail.example.com gives postmaster.example.com and webmail.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, so postmaster. is a convenience; webmail. is where your users actually go. With tls.mode=acme Caddy obtains a Let’s Encrypt certificate as soon as https://<hostname> resolves to the host; cert-sync hands it to Postfix/Dovecot within 30 s.

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.

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.

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