- Python 62.1%
- Shell 37.9%
| bin | ||
| coolify | ||
| .gitignore | ||
| install-coolify.sh | ||
| install.sh | ||
| README.md | ||
| stack.env.example | ||
wp-mail-stack
Coolify-based stack for a single VPS hosting multiple WordPress sites and full email: 1 domain = 1 WordPress = unlimited mailboxes. One command per site:
hostess add example.com
| Component | Role | URL |
|---|---|---|
| Coolify | Control plane: deploys, per-site DBs, TLS, dashboard | coolify.<primary> |
| Coolify proxy | Traefik v3 on 80/443 (+ optional ovh DNS-01 resolver) | — |
| Mailu 2024.06 | SMTP/IMAP, admin UI, webmail — standalone compose | mail.<primary> |
| WordPress | One Coolify compose resource per domain (own MariaDB) | <domain> |
| Homepage | Landing page | home.<primary> |
| Uptime Kuma | Monitoring + mail alerts | uptime.<primary> |
From zero to first site
1. Order a VPS. Any Debian-ish box, ≥4 GB RAM, public IPv4. Critical at the
provider: outbound port 25 must be unblocked (most providers block it by
default — request it, or mail will never leave the box). Create a regular user
with passwordless sudo, install Docker Engine + the compose plugin, add the
user to the docker group.
2. Order a domain. This becomes the primary domain — it names the server
itself (mail., coolify., home., uptime. live under it) and hosts the
admin/alert mailboxes. Buy it at OVH or DreamHost if you want hostess to manage
DNS automatically (see providers below); any registrar works with manual records.
3. Point DNS at the VPS. The installer creates the A records (mail,
coolify, home, uptime) itself when the zone is at a configured provider,
and tells you exactly what to paste otherwise. The one thing only you can do:
at the VPS provider, set the IP's PTR (reverse DNS) to mail.<primary> —
non-negotiable for deliverability (the installer checks and waits).
4. Bootstrap. Guided, curl|bash-able — checks what it can (docker, DNS, PTR, port 25), instructs and waits where it can't:
curl -fsSL https://git.hicham.party/hicham/wp-mail-stack/raw/branch/main/install.sh | bash
(equivalent manual route: clone to ~/srv, fill stack.env, run
./install-coolify.sh)
Idempotent; prompts for three passwords (Coolify root, mail admin, Kuma admin) and does the rest: Coolify + API token + instance domain + SMTP, firewall (dashboard port 8000 blocked externally), proxy customization, Mailu with admin/noreply mailboxes, Homepage, Uptime Kuma with monitors + mail alerts, backup crons.
5. Primary-domain mail records. bin/dns mail <primary> (auto if the zone
is at a configured provider, else it prints the MX/SPF/DKIM/DMARC records to
paste). Then send a message to https://www.mail-tester.com — aim ≥ 9/10. Expect
spam-folder placement at Gmail/Outlook for the first weeks regardless: fresh
domain + fresh IP need warm-up (send real mail, have recipients reply / mark
not-spam).
6. First site.
hostess add example.com
Does everything: DNS records (A/www/MX/SPF/DMARC/DKIM), Mailu mail domain +
noreply@ mailbox + DKIM key, Coolify WP resource with its own MariaDB, waits
for HTTPS, finishes the WordPress install (admin user + generated password),
activates WP Mail SMTP wired to Mailu. Prints all credentials; the WP admin
password is also stored on the Coolify resource as env WP_ADMIN_PASSWORD.
Day to day
hostess list # per site: coolify status, DNS, HTTPS, mail, DKIM
hostess add <domain> # new site (domain at any registrar)
hostess rm <domain> # retire site: resource + DNS (mail kept)
hostess rm <domain> --purge-mail # ... and delete mailboxes + mail data + DKIM
- Deploys, logs, restarts, env vars, DB backups: Coolify UI.
- Mailboxes/aliases: Mailu admin (
mail.<primary>/admin) ordocker compose --project-directory coolify/mailu exec admin flask mailu user <name> <domain> <pw>. - Site tiles on Homepage and cert renewals are automatic.
- Alerts (site down, cert expiry, deploy failure) land in
ALERT_EMAIL.
DNS providers
bin/dns (used by hostess) manages records through registrar APIs. Configured
in stack.env; both can coexist — the provider is picked per domain by
probing which account controls its zone (OVH: GET /domain/zone/<zone>/soa
walking the fqdn's suffixes; DreamHost: zone list from dns-list_records).
A domain in neither account isn't an error: hostess prints the records to
publish manually and carries on.
OVH — create a token at https://api.ovh.com/createToken/ with unlimited validity and rights:
GET /domain/zone/*
POST /domain/zone/*
DELETE /domain/zone/*
(no PUT needed — updates run as delete+create). The same creds power the
proxy's optional ovh DNS-01 resolver for wildcard certs.
DreamHost — one key from panel.dreamhost.com → API with the dns-*
functions. Caveats: no TTL control (slower propagation), records marked
non-editable are left untouched.
Semantics: idempotent upsert. Matching records are skipped; records
pointing elsewhere (e.g. a domain migrating from old managed hosting) are
taken over and rewritten to the VPS — printed as updated old -> new.
TXT replacement is kind-scoped (v=spf1/v=DMARC1/v=DKIM1 each only replace
their own kind), so unrelated TXT records like site verifications survive.
dns rm only deletes record names the tool itself would create.
Layout
<repo root>/ (e.g. ~/srv)
├── stack.env single config point (domain, emails, DNS creds)
├── install.sh guided curl|bash entrypoint (do-nothing style)
├── install-coolify.sh stack bootstrap, idempotent (called by install.sh)
├── coolify/
│ ├── proxy/ customized proxy compose + mailu router fragment
│ ├── mailu/ standalone mailu compose
│ ├── wp-template/ per-site WP+MariaDB compose (hostess feeds it to coolify)
│ └── homepage/ homepage compose + config templates
├── mailu/ runtime data (gitignored): mail data, certs, mailu.env
├── homepage/ runtime config (gitignored, rendered from templates)
└── bin/
├── hostess add / rm / list — the site lifecycle CLI
├── dns idempotent zone records, OVH + DreamHost (a/site/mail/rm)
├── _stack.py shared config/API module
├── coolify-deploy-compose service create/update helper (compose + oneclick)
├── kuma-setup kuma bootstrap: admin, SMTP alerts, monitors
└── backup nightly: WP DBs+volumes, mailu, coolify pg, acme.json
Why Mailu is NOT a Coolify resource
Coolify's compose transformer strips custom network config (static IPs, dns:
entries) that Mailu's unbound resolver needs. Mailu runs as plain compose; its
web UI is routed by /data/coolify/proxy/dynamic/mailu.yml (file provider, not
labels — front is unhealthy pre-cert and the docker provider ignores unhealthy
containers). front carries the mail.<primary> alias on the external
mailnet network so coolify + the proxy reach it directly (docker blocks
bridge-to-bridge hairpin via the public IP). A python sidecar extracts the mail
cert from coolify's acme.json. SMTP is port 465 only — TLS_FLAVOR=mail
disables 587. Never install a host MTA (watch apt recommends — exim4 will
race Mailu for port 25 at boot; the installer uses --no-install-recommends).
Backups
bin/backup nightly via cron: per-site DB dumps, WP + kuma volumes, mailu
data, coolify postgres, acme.json; 7-day local retention (backups/).
Off-box shipping is a marked TODO in the script.
Operational notes
- Coolify "stop" deletes service containers; find volumes by uuid.
- Coolify API: compose resources =
/api/v1/services,docker_compose_rawbase64 on POST/PATCH, plain yaml on GET;urls:[{name,url}]sets domains. - Coolify can't interpolate env vars in volume host paths — the deploy helper renders them into the compose before upload.
- Don't set
dns:overrides on containers — docker's embedded DNS forwarding through the host resolver is what mailu/wp need. - Uptime Kuma is pinned to 1.23.x (
uptime-kuma-apicannot drive 2.x). - Proxy compose is re-applied from
coolify/proxy/after a Coolify "reset proxy". flask shellscripting: end compound statements with a blank line — the REPL silently discards pending blocks at EOF.