Skip to content

Command Palette

Search for a command to run...

How to Deploy Mailu on a VPS

Updated Jul 2026

verified on Ubuntu 26.04 · Jul 2026

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

Before you start
  • 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:

  • DOMAIN vs HOSTNAMES are different concepts that happen to share a value here. DOMAIN is the mail domain your addresses live at (user@DOMAIN); HOSTNAMES is 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_KEY encrypts session data and signs internal tokens. Treat it like any other credential — back up mailu.env, never regenerate it on a running instance.
  • TLS_FLAVOR=letsencrypt means the front container 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.

Next steps

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.