How to Deploy Mailcow on a VPS
Updated Jul 2026
verified on Ubuntu 26.04 · Jul 2026Self-host Mailcow — a full mail suite bundling Postfix, Dovecot, SOGo webmail, Rspamd, and ClamAV behind one admin UI — on your own VPS with Docker Compose, as a self-hosted alternative to Google Workspace or Microsoft 365.
- A VPS with at least 6 GB RAM — Mailcow's hard minimum (plus 1 GB swap). 8 GB+ is the realistic recommendation for anything beyond a solo test instance.
- A fresh Ubuntu 26.04 server with root/sudo SSH access, Docker + Compose installed
- A domain you control, able to set MX, SPF, DKIM, DMARC, and PTR records — required for real mail delivery, not for the install itself
- Outbound port 25 unblocked by your VPS provider if you plan to send mail directly from this box — many providers block it by default; see Troubleshooting
What Mailcow is
Mailcow ("mailcow: dockerized") is a complete, self-hosted mail suite — not just an SMTP relay, but the whole stack a real mailbox provider runs: Postfix for sending and receiving mail, Dovecot for IMAP/POP3, SOGo for webmail and CalDAV/CardDAV, ActiveSync support for mobile mail clients, Rspamd for spam filtering, ClamAV for antivirus scanning, and an admin UI that ties it all together — domains, mailboxes, aliases, quotas, DKIM keys. It ships as roughly 20 Docker containers orchestrated by one Compose file, and it's licensed GPL-3.0.
The appeal is the same one that draws people to self-hosted infrastructure generally: cost and control at scale. Google Workspace and Microsoft 365 bill per mailbox per month — cheap for five people, expensive for fifty. A single well-sized VPS running Mailcow handles a meaningful number of mailboxes for the price of one Workspace seat, and your mail — the actual message content, not just metadata — never leaves infrastructure you control.
The honest trade-off: email is the least forgiving thing you can self-host. A misconfigured SPF record doesn't crash anything, it just means your mail silently lands in spam folders (or gets rejected outright) with no error you'll notice until someone complains. This guide gets Mailcow installed and booted correctly, which is a real milestone — but reliable delivery to Gmail, Outlook, and everyone else depends on DNS work covered in its own section below, and that part is on you.
Server sizing — 6 GB is the floor, not a comfortable number
Mailcow is the heaviest thing in this catalog, and it's upfront about it: the project's own minimum is 6 GB RAM plus 1 GB swap, and that's for a default config running a handful of mailboxes — it is not headroom, it is what ~20 containers actually need to start and stay up. Undersize this and containers get OOM-killed under normal load, not just spikes.
8 GB RAM is the realistic recommendation for 5–10 users, and the project suggests
16 GB if you're layering on 15+ mobile devices doing ActiveSync alongside ~50 concurrent
IMAP connections. Two containers account for most of the appetite: ClamAV
(antivirus signature database, loaded fully into memory) and full-text search
(indexing message bodies for webmail search) — both can be disabled in mailcow.conf
if you're truly resource-constrained, at the cost of losing virus scanning or webmail
search respectively. Don't do that as your default plan; do it only if you've confirmed
you need to.
Give the disk real room too — 40–60 GB is a sane starting point once you account for container images, the MySQL database, and actual mail storage that only grows. A Hetzner CX32 (4 vCPU / 8 GB) or Kamatera's equivalent 8 GB tier is the right starting point; don't try this on anything smaller.
Prepare the server
This guide assumes you've already worked through
Docker & Compose on Ubuntu — a fresh Ubuntu
26.04 box, a non-root deploy user, Docker Engine and the Compose plugin installed, and
ufw allowing SSH plus 80 and 443. If you haven't, do that first.
Mailcow is different from most guides in this catalog in one important way: it is its
own reverse proxy and its own TLS terminator. There's no Caddy or Nginx Proxy Manager
in front of it — the nginx-mailcow container binds directly to 80 and 443 on the host,
and an acme-mailcow container issues and renews its own Let's Encrypt certificate.
On top of that, Postfix and Dovecot need to be reachable directly from the internet
on the standard mail ports, because mail clients and other mail servers connect to them
straight, not through a proxy. So the base guide's firewall (SSH, 80, 443) isn't enough
here — open the mail ports too:
ufw allow 25/tcp
ufw allow 465/tcp
ufw allow 587/tcp
ufw allow 110/tcp
ufw allow 143/tcp
ufw allow 993/tcp
ufw allow 995/tcp
ufw allow 4190/tcp
ufw status
That's SMTP (25), SMTPS (465), submission (587), POP3/POP3S (110/995), IMAP/IMAPS (143/993), and ManageSieve (4190) — the full set a mail server needs open, in addition to 80/443 for the web UI, webmail, and ACME.
Install Mailcow
Mailcow's own installer needs a short list of system tools beyond Docker — make sure they're present:
apt-get update -y
apt-get install -y git curl jq gawk
Clone the repository into /opt, the path the project's own docs use:
cd /opt
git clone https://github.com/mailcow/mailcow-dockerized
cd /opt/mailcow-dockerized
Now generate mailcow.conf. The official flow runs ./generate_config.sh as an
interactive script — it prompts for the mail server hostname, timezone, and which
release branch to track, one read at a time. But each of those prompts is guarded by
a while [ -z "$VAR" ] loop, which means if the variable is already set in the
environment before the script runs, the loop condition is false and the prompt is
skipped entirely. Exporting the answers ahead of time makes the whole thing
non-interactive:
SERVER_IP="203.0.113.10" # replace with your VPS's public IP
export MAILCOW_HOSTNAME="mail.${SERVER_IP}.sslip.io"
export MAILCOW_TZ="UTC"
export MAILCOW_BRANCH="master"
export SKIP_CLAMD="n"
export FORCE=1
cd /opt/mailcow-dockerized
./generate_config.sh
A note on each of those, since they map straight onto prompts you'd otherwise see:
MAILCOW_HOSTNAMEis the mail server's hostname — not your mail domain. With no real domain yet,<subdomain>.<IP>.sslip.ioresolves straight back to your server's IP with zero DNS setup, which is exactly what this guide uses to get a working install. For real mail, you'll point this at a proper FQDN (see DNS below).MAILCOW_TZsets the container timezone; the script otherwise tries to auto-detect it from/etc/timezoneand still prompts to confirm.MAILCOW_BRANCHpicksmaster(stable),nightly, orlegacy— set it tomasterunless you specifically need one of the others.SKIP_CLAMDwould normally only prompt if the box has ≤2.5 GB RAM; setting it explicitly avoids depending on that check.FORCE=1covers a separate, IPv6-specific prompt the script can trigger if it wants to patch Docker'sdaemon.jsonfor IPv6 support — it makes that step auto-confirm instead of asking.
The script writes mailcow.conf (symlinked to .env, which already ships in the repo)
and does not start anything itself — pull the images and bring the stack up:
cd /opt/mailcow-dockerized
docker compose pull
docker compose up -d
That pulls roughly 20 images, so it takes a while on first run. Confirm the stack is up and check the web-facing container specifically:
docker compose ps
docker compose logs --tail 40 nginx-mailcow
docker compose logs -f
HTTPS + domain
Because Mailcow terminates its own TLS, there's no separate Caddy setup here — once
MAILCOW_HOSTNAME resolves to the server's public IP and ports 80/443 are reachable,
acme-mailcow requests and installs a Let's Encrypt certificate for that hostname
automatically. With an sslip.io hostname the DNS resolution is already correct by
construction, so issuance starts right away — but nginx serves on a self-signed cert
first and swaps in the Let's Encrypt one a few minutes later once acme-mailcow
finishes. Confirm the admin UI is answering over HTTPS from the server itself (the -k
tolerates that initial self-signed cert; the trusted cert lands on its own shortly after):
for i in $(seq 1 12); do
code=$(curl -sk -o /dev/null -w '%{http_code}' "https://${MAILCOW_HOSTNAME}/")
[ "$code" != "000" ] && { echo "admin UI up: HTTP $code"; break; }
sleep 5
done
A 200 (or a redirect code) means nginx is serving the admin UI over HTTPS. If
you already run another site on 80/443 on this box, Mailcow's docs cover putting it
behind an external reverse proxy instead — that's a deliberate deviation from this
guide's default setup and out of scope here.
First-run setup
Load https://<your-hostname>/admin in a browser — for this guide, that's
https://mail.<SERVER_IP>.sslip.io/admin.
Log in with the default credentials and change them immediately: username admin,
password moohoo. This default is published in Mailcow's own generated config file, so
treat it as public knowledge from the moment the stack is up — go to
Configuration → Access → Admin Details and set a strong password before doing
anything else.
Once you're in as admin:
- Add a mail domain under Configuration → Domains — this is the domain mail
will actually be sent and received for (distinct from
MAILCOW_HOSTNAME, the server's own hostname). - Add a mailbox on that domain and set a password for it.
- Log into webmail at
https://<your-hostname>/SOGo/with that mailbox's credentials, and send a test message to another mailbox on the same server to confirm the internal Postfix → Dovecot path works end to end. - Check the DKIM key was generated under Configuration → ARC/DKIM keys for the domain you added — Mailcow generates this automatically per domain, and you'll need its public key value for DNS in the next section.
Internal mail flowing between two mailboxes on the same server confirms the install itself is healthy. It does not confirm mail to and from the outside world works — that depends entirely on the DNS work below.
DNS & deliverability — not covered by install verification
This section is the reader's setup, and it is not something a fresh test VPS can verify. Getting a mail server to actually deliver to Gmail, Outlook, and everyone else depends on DNS records and sender reputation that only exist once you're running on a real domain with a stable IP — a throwaway verification box can confirm Mailcow boots, not that mail sent from it arrives. Treat everything below as required homework before you rely on this server for real mail:
- MX record on your mail domain, pointing at
MAILCOW_HOSTNAME(the server hostname, not the domain itself):example.com. MX 10 mail.example.com. - SPF (
TXTon the domain):v=spf1 mx -allis a reasonable starting point if this server is the only thing that sends mail for the domain. - DKIM — copy the public key Mailcow generated (Configuration → ARC/DKIM keys) into
a
TXTrecord at the selector name it shows you (typicallydkim._domainkey.example.com). - DMARC (
TXTat_dmarc.example.com): start permissive while you confirm delivery, e.g.v=DMARC1; p=none; rua=mailto:you@example.com, then tighten top=quarantineorp=rejectonce SPF/DKIM are confirmed passing. - PTR / reverse DNS — your VPS provider, not your DNS provider, controls this. Ask
them to set the server's IP to resolve back to
MAILCOW_HOSTNAME. A missing or mismatched PTR record is one of the most common reasons outbound mail lands in spam. - Port 25 outbound — many VPS providers block it by default on new accounts specifically to fight spam; you'll need to request it be unblocked (see Troubleshooting). Without it, this server cannot deliver mail directly to other servers at all.
None of this is verified by a successful install — a boot-verified Mailcow and a deliverable Mailcow are two different milestones, and only the first one is what this guide's verification checks.
Backups
Mailcow ships its own backup tool, and it's the right one to use — it knows which volumes and databases make up a complete instance:
export MAILCOW_BACKUP_LOCATION=/opt/mailcow-backups
mkdir -p /opt/mailcow-backups
cd /opt/mailcow-dockerized
./helper-scripts/backup_and_restore.sh backup all
That backs up MySQL, mail storage (vmail), Redis, Rspamd data, Postfix data, and the
encryption keys (crypt) into timestamped folders under MAILCOW_BACKUP_LOCATION. The
crypt volume is the one to treat like a root password — it holds the encryption keys
for stored mail, and a MySQL/vmail backup restored without the matching crypt data is
unreadable.
A backup that only lives on the same server dies with the server — get these archives
to an S3-compatible bucket or another machine on a schedule, and periodically test a
restore (backup_and_restore.sh restore) into a throwaway stack. An untested backup
is a guess.
Upgrades
Mailcow moves fast, and — being ~20 coupled containers — an upgrade touches a lot of
moving parts at once. Use the project's own update script rather than a manual
docker compose pull:
cd /opt/mailcow-dockerized
./update.sh --force
--force skips the script's confirmation prompts, which is what makes it safe to run
unattended here — only reach for that on a box where you've already read the release
notes, since it stops every container, pulls new images, and applies any pending
database migrations automatically. That's exactly why a current backup (above) before
upgrading is non-negotiable: a migration changes your data in place, and there's no
clean way back to an older version once it's run.
Troubleshooting
Outbound mail is rejected or never arrives, and Postfix logs show a connection timeout to port 25 on the receiving end. Your VPS provider is very likely blocking outbound port 25 by default — this is standard anti-spam policy at Hetzner, DigitalOcean, Kamatera, and most others for new accounts. Open a support ticket asking for it to be unblocked; most providers do this within a day once you explain the use case, some require a form.
Mail delivers but lands in spam. Check SPF/DKIM/DMARC are actually passing (mail
clients show this in message headers, or use an external mail-tester tool), and confirm
your PTR record resolves to MAILCOW_HOSTNAME. A fresh, low-volume sending IP also
just has no reputation yet — this improves over the first weeks of legitimate sending
volume; there's no shortcut around it.
docker compose ps shows a container repeatedly restarting. Almost always
insufficient RAM — check docker stats and free -h against the 6 GB minimum / 8 GB
recommended sizing above. ClamAV and full-text search are the two heaviest containers;
if you're genuinely resource-constrained, SKIP_CLAMD=y in mailcow.conf and a
docker compose up -d restart buys headroom at the cost of virus scanning.
The admin UI or webmail won't load over HTTPS. Confirm MAILCOW_HOSTNAME in
mailcow.conf matches the hostname you're browsing to exactly, and that
docker compose logs --tail 40 acme-mailcow shows a certificate was actually issued —
a DNS mismatch at the time acme-mailcow first ran is the most common cause of a
missing cert.
generate_config.sh still prompts despite the exported variables. Confirm you
exported each variable (not just set it) in the same shell that runs the script, and
that you're running it from inside /opt/mailcow-dockerized — the script refuses to
run anywhere else.
Verification + next steps
You're done with the install when: docker compose ps shows every container
healthy or running, https://<your-hostname>/admin loads over HTTPS with a valid
certificate, you've logged in and changed the default admin password, a mailbox exists
and can send/receive mail to another mailbox on the same server via webmail, and a DKIM
key has been generated for your domain.
You're done with deliverability — a separate, later milestone — only once MX, SPF, DKIM, DMARC, and PTR are all in place on a real domain, outbound port 25 is confirmed open, and a test message to an external mailbox (Gmail, for instance) both arrives and lands outside spam. Budget real time for that second part; it's DNS propagation and sender-reputation building, not a configuration step you can rush.
Given the RAM floor, size up front rather than resizing later — a Hetzner CX32 or Kamatera's 8 GB tier is the sensible starting point for Mailcow specifically, even though smaller boxes work fine for lighter self-hosted apps. See Best VPS for self-hosting for the ranked picks.