Installation
Deploy Mailyard with Docker Compose or build it from source
Mailyard is a single binary. It embeds the console frontend, this documentation, and its database migrations, so a deployment is one container plus a PostgreSQL.
The delivery queue lives in PostgreSQL and the worker runs inside the server process, so mailyard serve is the whole
system. When one process is no longer enough, several run against the same database off the same binary and the same
config. See Scaling out
.
Docker Compose
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: mailyard
POSTGRES_PASSWORD: change-me
POSTGRES_DB: mailyard
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U mailyard -d mailyard" ]
interval: 5s
retries: 10
restart: unless-stopped
mailyard:
image: mailyard:latest
# --init applies pending migrations. See the note below.
command: [ "serve", "--init" ]
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
# Only if you enable the SMTP listeners.
- "587:587"
- "25:25"
environment:
MAILYARD_DATABASE_DSN: postgres://mailyard:change-me@postgres:5432/mailyard?sslmode=disable
# Both REQUIRED, each from `openssl rand -hex 32`. Mailyard refuses
# to start until they are set. The encryption key is what seals
# stored secrets - losing it means losing them.
MAILYARD_AUTH_JWT_SECRET: ""
MAILYARD_DATABASE_CRYPTO_ENCRYPTION_KEY: ""
MAILYARD_AUTH_LOCAL_ENABLED: "true"
MAILYARD_AUTH_LOCAL_EMAIL: admin@example.com
MAILYARD_SERVER_PUBLIC_URL: https://mail.example.com
healthcheck:
test: [ "CMD", "wget", "-qO-", "http://localhost:3000/healthz" ]
interval: 30s
start_period: 15s
retries: 3
restart: unless-stopped
volumes:
pgdata:
docker compose up -d
--init is not the default, and without it the first boot fails
A serving node checks that the schema matches the binary and exits if it does not — an empty database included. So
a plain serve against a fresh Postgres will not start, and that is deliberate: the alternative is an upgraded binary
quietly serving from last month’s schema, with the features its new columns belong to silently broken.
--init applies pending migrations first. On a single node, leave it in the command permanently. In a fleet, exactly
one node carries it and the rest start once it has finished — two processes migrating an empty database at the same
time race each other and it comes out as a duplicate key.
MAILYARD_SERVER_PUBLIC_URL is worth setting before the first send rather than after. Tracking links, hosted
unsubscribe pages and invitation mail are all absolute URLs built from it, and
campaigns refuse to start
without it.
Attachments are stored in the database by default
With MAILYARD_STORAGE_BACKEND unset, every attachment is kept inline, base64 encoded, in PostgreSQL - a third larger
than the file, in the same tables the delivery queue works on. That needs no setup and is meant for trying Mailyard out
and for testing. For production set MAILYARD_STORAGE_BACKEND to fs (on a persistent volume) or s3, so the
database holds only a reference. Decide before real traffic: the setting applies to mail accepted after it changes, and
attachments already stored inline stay in the database until retention clears them. See
Attachment Storage
.
On the first start the bootstrap admin password is printed to stderr once — read it out of the container log before doing anything else:
docker compose logs mailyard | grep -i password
The console is then at http://localhost:3000/app.
Tip
Lost the bootstrap password? mailyard set-password resets it from the command line against the same database.
Need a new encryption key? mailyard rekey re-encrypts every sealed column under it, with the nodes stopped.
TLS
The configuration file answers one question about TLS: whether each listener terminates it. It says nothing about which certificate — that is chosen in the console and stored in the database, so every node serves the same one and a replacement needs no restart.
| Variable | Default | What it does |
|---|---|---|
MAILYARD_SERVER_TLS_ENABLED | false | HTTPS on the console and API |
MAILYARD_SUBMISSION_TLS_ENABLED | true | STARTTLS on submission |
MAILYARD_INBOUND_TLS_ENABLED | true | STARTTLS on the MX listener |
The HTTP default is off because the common deployment puts a reverse proxy in front that terminates TLS and speaks plain HTTP upstream. Turn it on to serve HTTPS directly.
Terminating TLS in Mailyard
Bind 443 and switch the listener on. There is nothing else to configure: with nothing assigned, a self-signed pair is generated on first use and shared by every node, so the console is reachable immediately over HTTPS with a browser warning.
services:
mailyard:
ports:
- "443:443"
- "587:587"
- "25:25"
environment:
MAILYARD_SERVER_ADDR: ":443"
MAILYARD_SERVER_TLS_ENABLED: "true"
MAILYARD_SERVER_PUBLIC_URL: https://mail.example.com
Then sign in and replace that pair under Administration → Certificates: upload one you already have, generate one signed by an internal CA, or order one from Let’s Encrypt. See Certificates .
public_url must match the scheme and host you browse
It decides the Secure flag on the session cookie, so https:// in the setting plus http:// in the browser means the
login succeeds, the browser discards the cookie, and every request after it is unauthenticated — with nothing in the
response to say why.
A bare IP address has the same effect in Safari, which will not keep a cookie for a host with no registrable domain. Give the installation a hostname.
Locked out by an assignment
mailyard tls unassign --listener server writes to the database with no server running, and drops the listener back to
the self-signed pair.
Ordering from Let’s Encrypt
ACME is turned on in the console under Administration → Certificates, not in the config file — see Certificates . The order is:
- Make sure the CA can reach this installation, which is one of two ways:
tls-alpn-01, when the TLS handshake on port 443 reaches Mailyard — bound directly withMAILYARD_SERVER_TLS_ENABLED: "true"as above, or through a TCP-passthrough proxy, which preserves ALPN. The handshake is the validation, and port 80 is never used.http-01, behind a proxy that terminates TLS, where the handshake never gets to us. SetMAILYARD_ACME_CHALLENGE_ADDR(empty by default) and have the proxy forward/.well-known/acme-challenge/*for the mail hostname to it. The port is bound at startup whether ACME is on or not, so this is the one step that needs a restart. The proxy route, with a Caddy example, is in Certificates .
- Sign in, open Administration → Certificates → Settings, and set
acme_enabled, thenacme_hosts— one hostname per line. Optionallyacme_email, where the CA sends expiry warnings. - Press Order beside a host. It is synchronous, so a refusal comes back with the CA’s own words. Nothing else needs a restart: the settings are read fresh on every handshake and every order.
An empty host list issues nothing, and says nothing
acme_hosts has no default — the name in public_url is not used. With the list empty, every name falls through to
the self-signed pair exactly as it does when ACME is off, and the only symptom is a certificate that never changes.
A hostname that is not in the ACME list falls through to the self-signed pair rather than failing the handshake, so a mail listener answering under a name the certificate does not cover still offers working STARTTLS.
Ports
| Port | Listener | Notes |
|---|---|---|
| 3000 or 443 | HTTP console and API | 443 only if this process terminates TLS |
| 587 | SMTP submission | Off by default |
| 25 | Inbound MX | Off by default |
| 80 | ACME HTTP-01 challenge | Only when ACME cannot use tls-alpn-01, and any unpublished port when a proxy forwards to it |
443, 587, 25 and 80 are all privileged. Docker sets
net.ipv4.ip_unprivileged_port_start=0 inside containers, so the published image binds them as uid 1000 with no extra
configuration. Kubernetes does not — grant
NET_BIND_SERVICE. Elsewhere, bind a high port and map the low one to it.
Scaling out
Run more replicas of the same container against the same database. Every node runs the worker, and delivery is claimed
with a conditional UPDATE, so two nodes cannot send the same message twice.
Two things to know before you do:
- Rate limits are per process. The HTTP limiters are in-process fixed windows, so three replicas mean three times the configured ceiling. Size them accordingly, or apply the limit at a shared proxy instead.
- Scheduled jobs run on every node. There is no leader election. The jobs are written to be idempotent and safe to run concurrently.
Build from Source
Prerequisites
- Go 1.27+
- PostgreSQL 15+
- Node.js 18+, to build the console
- go-task
3+, which runs the build steps
(
go install github.com/go-task/task/v3/cmd/task@latest)
PostgreSQL 15 is a hard floor, not a preference: the schema uses
UNIQUE NULLS NOT DISTINCT, which 14 cannot parse, so migrations fail outright on an older server rather than
degrading.
Steps
git clone https://github.com/yousysadmin/mailyard.git
cd mailyard
# Builds the console into web/dist, the docs into docs/dist, then the binary.
task build
# --init applies the schema. Exactly one node in a fleet carries it;
# a single-node install always does.
./bin/mailyard serve --init
task build is what produces a binary with the console and documentation inside it. A bare go build ./... compiles
and is fine for development, but the resulting binary serves no console and no docs, because the embedded directories
are empty.
Configuration comes from ./mailyard.yaml (see ../../../examples/mailyard.yaml.example) or from the environment.
See Configuration
.
Health Checks
# Liveness - the process is up.
curl http://localhost:3000/healthz
# Readiness - the process is up and the database answers.
curl http://localhost:3000/readyz
Use /healthz for a restart probe and /readyz to gate traffic. Pointing a restart probe at /readyz means a brief
database blip restarts every node at once, which turns a recoverable outage into a longer one.