How to Deploy Mailu on a VPS
Updated Jul 2026
verified on Ubuntu 26.04 · Jul 2026Self-host Mailu on your own VPS — a full-stack Docker mail server (SMTP/IMAP, webmail, antispam, admin panel) as a Google Workspace alternative for small teams.
- A VPS with at least 1 vCPU / 1–2 GB RAM (+1 GB swap; 3 GB RAM if you later enable ClamAV antivirus)
- A fresh Ubuntu 26.04 server with root/sudo SSH access, Docker + Compose installed
- A real domain you control, with the ability to add MX/SPF/DKIM/DMARC records and a PTR record — required before you send or receive real mail (not needed to install and boot the stack)
What Mailu is
Mailu is a full-stack, self-hosted mail server distributed as a set of Docker images: Postfix for SMTP, Dovecot for IMAP, Rspamd for antispam, an admin web panel, and a webmail client (Roundcube or SnappyMail). Put together, it replaces Google Workspace or Microsoft 365 for a small team — you get real mailboxes, aliases, and a webmail UI, running on a box you own instead of a per-seat SaaS bill.
Mailu's normal on-ramp is a web setup wizard at setup.mailu.io: you pick your options (webmail
flavor, antivirus, TLS mode) and it generates a docker-compose.yml and a mailu.env file for you to
download and run. This guide skips the wizard and gives you both files directly, pinned to a specific
Mailu release, with the minimal set of services a small team actually needs — no antivirus, no WebDAV,
no full-text attachment indexing. You can always regenerate a fuller docker-compose.yml from the
wizard later and layer in what's here.
Running a mail server is the highest-stakes thing in this guide series. Get the containers wrong and you fix a bug; get deliverability wrong and your mail silently lands in spam folders or bounces outright, for weeks, before you notice. This guide gets Mailu installed and booted correctly. The DNS and reputation work that makes mail actually arrive is a separate section below, and it is yours to do — see the honesty note before you send anything real.
Server sizing
Mailu itself is light: 1 GB of RAM plus 1 GB of swap is enough for the base stack (front, admin,
imap, smtp, antispam, redis, webmail) at small-team volume. The one thing that changes this math is
ClamAV antivirus, which is memory-hungry — enabling it pushes the requirement to 3 GB of RAM.
This guide leaves antivirus off (ANTIVIRUS=none) to keep the stack in the cheap tier; turn it on later
by adding the antivirus service and pointing ANTIVIRUS=clamav at it once you've sized up the box.
A Hetzner CX22 or the smallest Kamatera/DigitalOcean tier with 2 GB RAM gives you comfortable headroom above the 1 GB floor. Disk is cheap to reason about too — mailbox storage is the only thing that grows meaningfully over time, so 20–40 GB is plenty for a small team and you can resize later.
Prepare the server
This guide assumes you've already worked through Docker & Compose on Ubuntu — a fresh Ubuntu 26.04 box, Docker Engine, and the Compose plugin installed. Mail is unusual among self-hosted apps in that it needs a wide spread of ports open directly to the internet, not just 80/443: SMTP (25, 465, 587), IMAP (143, 993), POP3 (110, 995), and Sieve (4190), on top of the web ports for the admin panel and webmail. There is no reverse proxy hiding this — Mailu's own front-end container binds these ports and handles its own TLS, because a plain HTTP proxy like Caddy can't forward raw SMTP/IMAP traffic.
Open ufw for all of it up front:
ufw allow proto tcp from any to any port 80,443,25,465,587,110,995,143,993,4190
Create a working directory for the stack:
mkdir -p ~/mailu && cd ~/mailu
Install Mailu
Mailu needs to know its own public hostname before it can request a TLS certificate or generate correct
mail headers. Detect the server's public IP and build a hostname from it — this guide uses
sslip.io, a DNS service that resolves <anything>.<ip>.sslip.io to <ip> for free,
so you get a real, publicly resolvable hostname without owning a domain yet:
SERVER_IP=$(curl -fsS https://ipv4.icanhazip.com | tr -d '[:space:]')
MAIL_DOMAIN="mail.${SERVER_IP}.sslip.io"
echo "SERVER_IP=${SERVER_IP}" > ~/mailu/.mailu-vars
echo "MAIL_DOMAIN=${MAIL_DOMAIN}" >> ~/mailu/.mailu-vars
cat ~/mailu/.mailu-vars
For a real deployment, replace this with your actual domain — set MAIL_DOMAIN to something like
mail.yourcompany.com instead, and see the DNS section below. sslip.io is a fine stand-in for standing
the stack up and confirming it boots; it cannot receive real mail for a domain you don't control.
Generate a secret key and write mailu.env — the single env file every Mailu container reads:
SECRET_KEY=$(openssl rand -hex 16)
cat > mailu.env <<EOF
SECRET_KEY=${SECRET_KEY}
SUBNET=192.168.203.0/24
DOMAIN=${MAIL_DOMAIN}
HOSTNAMES=${MAIL_DOMAIN}
POSTMASTER=admin
TLS_FLAVOR=letsencrypt
DISABLE_STATISTICS=True
ADMIN=true
WEBMAIL=roundcube
API=false
WEBDAV=none
ANTIVIRUS=none
SCAN_MACROS=false
MESSAGE_SIZE_LIMIT=50000000
RECIPIENT_DELIMITER=+
FULL_TEXT_SEARCH=en
WEBROOT_REDIRECT=/webmail
WEB_ADMIN=/admin
WEB_WEBMAIL=/webmail
WEB_API=/api
SITENAME=Mailu
WEBSITE=https://${MAIL_DOMAIN}
COMPOSE_PROJECT_NAME=mailu
CREDENTIAL_ROUNDS=12
REAL_IP_HEADER=
REAL_IP_FROM=
REJECT_UNLISTED_RECIPIENT=no
LOG_LEVEL=INFO
TZ=Etc/UTC
DEFAULT_SPAM_THRESHOLD=80
EOF
chmod 600 mailu.env
A few of these matter more than they look:
DOMAINvsHOSTNAMESare different concepts that happen to share a value here.DOMAINis the mail domain your addresses live at (user@DOMAIN);HOSTNAMESis the server's own name, used for its TLS certificate and SMTP banner. In a real deployment with your own domain, these often do differ (DOMAIN=yourcompany.com,HOSTNAMES=mail.yourcompany.com) — they're only the same value here because sslip.io gives us one throwaway hostname to work with.SECRET_KEYencrypts session data and signs internal tokens. Treat it like any other credential — back upmailu.env, never regenerate it on a running instance.TLS_FLAVOR=letsencryptmeans thefrontcontainer requests and renews its own certificate directly — no separate reverse proxy needed, which is why this guide doesn't use the Caddy guide.
Now write the compose file, pinned to a specific Mailu release rather than latest or master:
cat > docker-compose.yml <<'EOF'
services:
redis:
image: redis:alpine
restart: unless-stopped
volumes:
- redis_data:/data
front:
image: ghcr.io/mailu/nginx:2024.06.56
restart: unless-stopped
env_file: mailu.env
ports:
- "80:80"
- "443:443"
- "25:25"
- "465:465"
- "587:587"
- "110:110"
- "995:995"
- "143:143"
- "993:993"
- "4190:4190"
volumes:
- certs:/certs
- ./overrides/nginx:/overrides:ro
depends_on:
- admin
networks:
- default
- webmail
admin:
image: ghcr.io/mailu/admin:2024.06.56
restart: unless-stopped
env_file: mailu.env
volumes:
- data:/data
- dkim:/dkim
depends_on:
- redis
networks:
- default
imap:
image: ghcr.io/mailu/dovecot:2024.06.56
restart: unless-stopped
env_file: mailu.env
volumes:
- mail:/mail
- ./overrides/dovecot:/overrides:ro
depends_on:
- front
networks:
- default
smtp:
image: ghcr.io/mailu/postfix:2024.06.56
restart: unless-stopped
env_file: mailu.env
volumes:
- mailqueue:/queue
- ./overrides/postfix:/overrides:ro
depends_on:
- front
networks:
- default
antispam:
image: ghcr.io/mailu/rspamd:2024.06.56
hostname: antispam
restart: unless-stopped
env_file: mailu.env
volumes:
- filter:/var/lib/rspamd
- ./overrides/rspamd:/overrides:ro
depends_on:
- front
- redis
networks:
- default
webmail:
image: ghcr.io/mailu/webmail:2024.06.56
restart: unless-stopped
env_file: mailu.env
volumes:
- webmail_data:/data
- ./overrides/roundcube:/overrides:ro
depends_on:
- front
networks:
- webmail
networks:
default:
driver: bridge
ipam:
driver: default
config:
- subnet: 192.168.203.0/24
webmail:
driver: bridge
volumes:
redis_data:
certs:
data:
dkim:
mail:
mailqueue:
filter:
webmail_data:
EOF
This is a deliberately trimmed version of what the setup wizard generates: seven services instead of
the full set (no resolver, antivirus, webdav, fetchmail, oletools, or tika containers), which
keeps the stack inside the 1 GB RAM floor from the sizing section above. Each of those is a real,
supported Mailu feature you can add later — just not required to get a working mail server.
Create the override directories Docker expects (empty is fine — they let you drop in custom config later without touching the images) and bring the stack up:
mkdir -p overrides/nginx overrides/dovecot overrides/postfix overrides/rspamd overrides/roundcube
docker compose pull
docker compose up -d
Give it a moment to initialize — the admin container bootstraps its database and the front
container starts its Let's Encrypt handshake — then check status:
sleep 20
docker compose ps
docker compose logs --tail 50
All seven containers should show as running. front is the one to watch: it needs MAIL_DOMAIN to
resolve publicly (sslip.io does this instantly) before its ACME challenge can succeed.
First-run setup
Mailu has no web-based first-run wizard for the owner account — you create it from the CLI. This is actually safer than the open-registration screen other tools use: there's no window where an unauthenticated visitor can claim ownership.
ADMIN_PASSWORD=$(openssl rand -base64 18)
echo "${ADMIN_PASSWORD}" > admin-password.txt
chmod 600 admin-password.txt
docker compose exec -T admin flask mailu admin admin "${MAIL_DOMAIN}" "${ADMIN_PASSWORD}"
echo "Admin login: admin@${MAIL_DOMAIN}"
flask mailu admin <localpart> <domain> <password> creates a user and grants it admin rights over
that domain in one step. admin-password.txt now holds the password — move it somewhere safe and delete
it from the server once you've logged in and saved it in a password manager.
Confirm the admin panel and webmail are actually reachable over HTTPS. The certificate can take a few seconds to finish issuing on first boot, so this polls briefly rather than checking once:
for i in $(seq 1 24); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' -k "https://${MAIL_DOMAIN}/admin/ui/login")
if [ "${CODE}" = "200" ]; then
echo "admin UI responded HTTP ${CODE} after $((i * 5))s"
break
fi
sleep 5
done
curl -sI -k "https://${MAIL_DOMAIN}/webmail/" | head -1
If both return 200, load https:// + your MAIL_DOMAIN in a browser, log into /webmail with
admin@<your domain> and the password from admin-password.txt, and confirm the Roundcube inbox loads
(empty, since no mail has arrived yet — that's expected). Then check /admin and confirm you can see
the domain and the admin user under Mail domains.
DNS + deliverability — read this before you send real mail
This is the section that separates a booted mail server from one that actually delivers mail, and it
is not something a guide or a throwaway VPS can verify for you. Everything above gets Mailu installed,
listening, and reachable over HTTPS on mail.<your-ip>.sslip.io. None of it proves a single email will
arrive in someone's inbox — that depends on DNS records and sending reputation tied to a real domain,
which sslip.io deliberately doesn't give you.
Before sending anything real, on your actual domain:
- MX record — points your domain at your mail server's hostname (
yourcompany.com. MX 10 mail.yourcompany.com.), so other servers know where to deliver. - SPF record (TXT) — authorizes your server's IP to send for your domain
(
v=spf1 mx ~all). Without it, receiving servers have no way to tell your mail from a spoofer's. - DKIM record (TXT) — Mailu generates a DKIM keypair per domain automatically; the public key and the exact record to publish are shown in the admin panel under Mail domains → your domain → DKIM. Publish it before sending your first message.
- DMARC record (TXT) — tells receivers what to do with mail that fails SPF/DKIM
(
v=DMARC1; p=quarantine; rua=mailto:postmaster@yourcompany.com), and gets you reports when someone spoofs your domain. - PTR (reverse DNS) — your VPS provider sets this, usually in their control panel, not your DNS host.
It should resolve your server's IP back to
mail.yourcompany.com. Most receiving servers (Gmail especially) treat mail from an IP with no matching PTR as spam by default. - Port 25 outbound — some VPS providers block it by default on new accounts to fight spam operations; you may need to open a support ticket to unblock it before you can send mail to the world.
dig +short MX yourcompany.com
dig +short TXT yourcompany.com | grep spf
dig -x YOUR.SERVER.IP +short
In short: install-verified and delivery-verified are two different claims. This guide (and its verification stamp, if this page shows one) covers the former — Mailu boots correctly on Ubuntu 26.04 and serves its admin panel and webmail over HTTPS. Getting real mail delivered to Gmail/Outlook inboxes is DNS, reputation, and IP-warming work that's genuinely yours to do, and can take days to settle even once every record above is correct.
Backups
Three things matter: the mail volume (every mailbox, the actual content you can't regenerate),
the dkim volume (your signing keys — losing these means re-publishing DNS and a reputation hit),
and mailu.env (holds SECRET_KEY). Stop the stack briefly for a consistent cold backup, tar the
volumes, then bring it back up:
docker compose stop
tar czf ~/mailu-backup-$(date +%F).tar.gz -C /var/lib/docker/volumes \
mailu_mail mailu_dkim mailu_data mailu_certs
cp mailu.env ~/mailu-env-backup-$(date +%F).env
docker compose start
Copy both files off the box — an S3-compatible bucket or another machine — and periodically restore them into a throwaway stack to confirm the backup actually works. A backup you haven't restored is a guess, and for a mail server that guess includes your DKIM identity.
Upgrades
New Mailu releases land roughly monthly. Pull the pinned tag's replacement and recreate:
docker compose pull
docker compose up -d
docker compose logs --tail 30
Read the release notes before bumping the version, especially across year-boundary releases
(2024.06 → the next series) — Mailu occasionally changes mailu.env variable names or defaults, and
the notes tell you what to add or rename. Bump the 2024.06.56 tag in docker-compose.yml deliberately
rather than riding latest, back up first, then upgrade.
Troubleshooting
front never gets a certificate / admin panel loads with a browser TLS warning. Almost always DNS:
Let's Encrypt's HTTP-01 challenge needs MAIL_DOMAIN to resolve to this server's IP before the
container starts. sslip.io resolves instantly, so on the demo domain this usually means ports 80/443
aren't actually open — recheck ufw and your provider's firewall/security-group rules.
docker compose logs -f front
Mail from your domain lands in spam, or bounces with a reputation error. This is a DNS/reputation issue, not a container issue — revisit the DNS section above. The most common single cause is a missing or mismatched DKIM record; the admin panel shows the exact record Mailu expects.
docker compose exec -T admin flask mailu admin ... fails with "already exists." You're re-running
user creation against an account that's already there. Reset a password instead:
docker compose exec -T admin flask mailu password admin@yourcompany.com 'NewPassword'
A container restarts in a loop after docker compose up -d. Check that container's own logs
(docker compose logs <service> --tail 100) rather than the aggregate — admin failing usually means a
mailu.env typo, front failing usually means a port conflict (something else already bound to 25 or
443) or a DNS/TLS issue as above.
Verification + next steps
You're done when: all seven containers show running, the admin panel and webmail both respond 200
over HTTPS on your MAIL_DOMAIN, you've logged into webmail as the admin user, and a backup plus
mailu.env are sitting in off-box storage. That's the install-verified bar this guide targets.
From there, the real work starts: point a real domain at the server, publish MX/SPF/DKIM/DMARC/PTR, confirm port 25 is open outbound, and send yourself a test message from an external account — Gmail's own mail tester tools are a good sanity check before you migrate a whole team's inbox over. A Hetzner CX22 or a 2 GB DigitalOcean droplet handles this stack comfortably; see Best VPS for self-hosting for the ranked picks.