No description
  • Python 62.1%
  • Shell 37.9%
Find a file
2026-08-22 17:45:27 +02:00
bin simplify: shared bin/_stack.py module, dns zone cache and filtered removal, single-boot mailu provisioning, parallel list, oneclick mode merged into deploy helper, yaml anchors in mailu compose, stack.env as single config source 2026-08-22 17:32:44 +02:00
coolify simplify: shared bin/_stack.py module, dns zone cache and filtered removal, single-boot mailu provisioning, parallel list, oneclick mode merged into deploy helper, yaml anchors in mailu compose, stack.env as single config source 2026-08-22 17:32:44 +02:00
.gitignore generic fresh-vps deployment: stack.env config, install-coolify.sh bootstrap, drop legacy stack and host-specific values 2026-08-20 14:31:05 +02:00
install-coolify.sh simplify: shared bin/_stack.py module, dns zone cache and filtered removal, single-boot mailu provisioning, parallel list, oneclick mode merged into deploy helper, yaml anchors in mailu compose, stack.env as single config source 2026-08-22 17:32:44 +02:00
install.sh install.sh: fail fast without a tty, default ask value 2026-08-22 17:45:27 +02:00
README.md readme: bin layout after consolidation 2026-08-22 17:32:58 +02:00
stack.env.example simplify: shared bin/_stack.py module, dns zone cache and filtered removal, single-boot mailu provisioning, parallel list, oneclick mode merged into deploy helper, yaml anchors in mailu compose, stack.env as single config source 2026-08-22 17:32:44 +02:00

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) or docker 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 onlyTLS_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_raw base64 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-api cannot drive 2.x).
  • Proxy compose is re-applied from coolify/proxy/ after a Coolify "reset proxy".
  • flask shell scripting: end compound statements with a blank line — the REPL silently discards pending blocks at EOF.