Building a Self-Hosted Mail Server and Phishing Simulation Stack

TL;DR: Stalwart + GoPhish + Nginx on a $2 VPS, behind Stalwart-managed DNS. The build itself took a weekend; getting mail to actually land in inboxes takes longer because of PTR, IP reputation, and warm-up. Two services wanted port 443, a config file didn't actually contain the config, and Gmail still flags the mail as spam because I don't have a PTR record yet. Full build below — and I'll keep updating it as the rough edges get fixed.
I run a phishing simulation platform for a small security awareness program. The commercial tools were either too expensive or too limited, so I rolled my own on a single VPS. This is the full build: Stalwart for the mail server, GoPhish for the campaigns, Nginx in front, and a real DNS configuration that doesn't immediately scream "spam."
It's also a post about the parts that broke. Two services fighting for port 443, a config file that didn't actually contain the config, the certbot rate limit I hit at 11pm. If you're building something similar, you'll probably hit at least one of them.
Before I go further: this is for legitimate phishing awareness training of people who have consented to participate. Phishing without consent is illegal. Don't be the reason security awareness programs get a worse reputation.
Why Build This
Phishing is still the number-one initial access vector in breach reports. What's less appreciated is how badly most "training" misses the mark. A yearly slideshow, a fake phishing email from IT that everyone forwards as a joke — these don't change behavior. They check a compliance box.
The tools that actually work do a few things: realistic-looking lures, immediate feedback when someone clicks, metrics that show improvement over time, and templates that match the threats your org actually faces. Commercial platforms like KnowBe4 do this well. They also cost per-seat, which adds up fast for a small team.
GoPhish does most of what the paid tools do, for free, and lets you host everything yourself. The trade-off is that you run the infrastructure. Which is what I wanted anyway.
The Stack
VPS: $2/month box with 1 vCPU / 2GB RAM / ~40GB SSD, Ubuntu 24.04
Domain: yours, pointed at the VPS
Mail server: Stalwart v0.16.19
Phishing framework: GoPhish v0.12.1
Reverse proxy: Nginx
Database: SQLite (single-file, simple to back up)
SSL: Let's Encrypt via certbot
DNS: Stalwart's automated provisioning against Spaceship (custom provider — Spaceship isn't in the native list, but the API is workable)
I picked Stalwart over Postfix+Dovecot+Roundcube because it's a single binary with sensible defaults and a working admin UI out of the box. GoPhish because it's the de facto standard for open-source phishing simulation. SQLite because I'm not running a busy mail server for thousands of users — if I were, I'd use Postgres.
Phase 1: VPS Hardening
The mail server gets exposed to the open internet the moment you point DNS at it. Hardening comes first.
# Create non-root user
adduser youruser
usermod -aG sudo youruser
# SSH key only, no root login
sudo nano /etc/ssh/sshd_config
# PermitRootLogin no
# PasswordAuthentication no
sudo systemctl restart sshd
# Firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22,80,443/tcp
sudo ufw allow 25,465,587,993,995,4190/tcp
sudo ufw enable
# Fail2Ban
sudo apt install fail2ban -y
sudo systemctl enable --now fail2ban
# Automatic security updates
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades
Two gigabytes of swap on a 2GB box. Cheap insurance against OOM during DKIM signing, IMAP sync, or when Stalwart is regenerating its listener config.
Phase 2: Stalwart
The official Stalwart Linux install is a one-shot script that handles the binary, the service account, the systemd unit, and the FHS paths. It saves an evening.
curl --proto '=https' --tlsv1.2 -sSf https://get.stalw.art/install.sh -o install.sh
sudo sh install.sh
The script drops the binary at /usr/local/bin/stalwart, the config at /etc/stalwart/config.json, env vars at /etc/stalwart/stalwart.env, app data at /var/lib/stalwart/, and logs at /var/log/stalwart/. It creates a stalwart system user, writes the systemd unit, enables the service, and starts it. If your /opt is on a separate volume and you want everything under there, pass it as a prefix: sudo sh install.sh /opt/stalwart.
If you'd rather do it by hand — useful for understanding what the script does, or for fitting an existing layout — the manual install walks through the same steps.
Either way, on first start Stalwart runs in bootstrap mode and prints a temporary admin password to the logs. Log in at http://YOUR_IP:8080, complete the setup wizard (SQLite, your domain, a permanent admin account), then systemctl restart stalwart.
The web UI on port 8080 is fine for local access, but I don't want it on the public internet. Same for GoPhish. That's where Nginx comes in.
Phase 3: DNS (Automated)
This is where most self-hosted mail setups fall over. Gmail and Outlook will reject your mail if your DNS isn't right. Doing it by hand is tedious and easy to mess up: MX, SPF, DKIM, DMARC, MTA-STS, TLS-RPT — at least five different record types, all of which need to point at the right place and stay in sync when the server's IP changes.
Stalwart has automated DNS provisioning that does this for you. Give it API credentials for your DNS provider, point it at the domain, and it creates and rotates the records on its own. DKIM keys are generated server-side, signed automatically, and pushed to DNS. If you change the server's IP, the records update with it.
The catch: the list of providers Stalwart supports natively is short. Cloudflare, deSEC, Route53, Bunny, Gandi, a few others.
If you're on Cloudflare or one of the supported providers, this phase is mostly "paste the API token into Stalwart, click test, done." If you're on something custom like me, budget a few hours for the bridge script.
Verify with:
dig MX yourdomain.com +short
dig TXT yourdomain.com +short
dig TXT <selector>._domainkey.yourdomain.com +short
If those return your values, DNS is good. If not, wait — propagation can take up to 48 hours, but usually under 10 minutes.
Phase 4: Getting Stalwart Out of the Way
By default, Stalwart listens on port 443 for its web admin. Nginx needs port 443 for the public reverse proxy. Two services can't bind the same port.
Move Stalwart to 127.0.0.1:8443 (HTTPS) and 127.0.0.1:8080 (HTTP) so only Nginx can reach it. I tried editing config.json first. Wrong file — the listener config lives in the SQLite database. The way out is stalwart-cli, which talks to Stalwart's JMAP API:
stalwart-cli --url http://127.0.0.1:8080 \
--user admin@yourdomain.com \
--password 'YOUR_PASSWORD' \
query NetworkListener
Find the listener bound to [::]:443 and update it:
stalwart-cli update NetworkListener <LISTENER_ID> \
--field bind='{"127.0.0.1:8443": true}'
Restart and confirm with ss -tlnp | grep stalwart — both should now show 127.0.0.1 instead of *:443.
Phase 5: Nginx as Reverse Proxy
Two server blocks, one per subdomain. Separate files for sanity:
/etc/nginx/sites-available/stalwart:
server {
listen 80;
server_name mail.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name mail.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/mail.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail.yourdomain.com/privkey.pem;
location / {
proxy_pass https://127.0.0.1:8443;
proxy_ssl_verify off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
/etc/nginx/sites-available/gophish:
server {
listen 80;
server_name gp.yourdomain.com;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
server_name gp.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/gp.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gp.yourdomain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3333;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable both:
sudo ln -s /etc/nginx/sites-available/{stalwart,gophish} /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Phase 6: Let's Encrypt
Once DNS propagates, certbot is one command per subdomain:
sudo certbot --nginx -d mail.yourdomain.com
sudo certbot --nginx -d gp.yourdomain.com
Certbot edits the Nginx config in place, requests the cert, runs the HTTP-01 challenge. Free certs, auto-renewed.
Heads up: Let's Encrypt rate-limits you to 5 failed authorizations per hour per domain. If your DNS isn't pointing at the server yet, or Nginx isn't serving the .well-known/acme-challenge path correctly, you'll burn through that fast. I did, at 11pm, on a Friday. Check with dig gp.yourdomain.com +short before running certbot.
Phase 7: GoPhish
wget https://github.com/gophish/gophish/releases/latest/download/gophish-linux-64bit.zip
unzip gophish-linux-64bit.zip
chmod +x gophish
/etc/systemd/system/gophish.service:
[Unit]
Description=Gophish
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/gophish
ExecStart=/opt/gophish/gophish
Restart=on-failure
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now gophish
sudo grep -i "password" /opt/gophish/gophish.log
That last command grabs the initial admin password from the log. Log in at https://gp.yourdomain.com, change the password, then go to Sending Profiles → New Profile.
This is where I hit another wall.
Phase 8: The "501 5.5.4" Problem
GoPhish's sending profile is just SMTP. Point it at Stalwart on 127.0.0.1:465, authenticate as admin@yourdomain.com, and try a test send. It will fail with a 501 error if the envelope sender doesn't match the authenticated user.
The fix is in Stalwart's web UI: Settings → MTA → Session → AUTH Stage. Toggle the option that allows authenticated users to send from any address. I clicked the wrong UI element the first time and got the same 501 back — make sure you're editing the AUTH stage, not the EHLO or MAIL stage.
After that, test sends go through. The SMTP test command I used most:
swaks --to admin@yourdomain.com \
--from admin@yourdomain.com \
--server 127.0.0.1 --port 465 \
--auth LOGIN --auth-user admin@yourdomain.com \
--auth-password 'YOUR_PASSWORD' --tlsc \
--body "test" --header "Subject: Test"
(swaks is apt install swaks — a Swiss-army SMTP testing tool.)
What I Did Wrong, So You Don't Have To
Forgot to add a DNS A record for the gp subdomain before running certbot. Five wasted attempts and a one-hour wait.
Tried to edit Stalwart's listener config in
config.json. It's in the database, not the config file. Use stalwart-cli or the web UI.Started sending mail before the automated DNS provisioning had finished syncing. The first few sends went out without DKIM in DNS and got flagged by Gmail. Test a send, then
digthe DKIM record, then send for real — even with automation, there's a small race window.
What's Not Done Yet (And Why It Matters)
This is a live build, not a finished one. As of writing:
PTR record. Reverse DNS, set by the VPS provider, not through your DNS UI. Without it, Gmail marks everything as spam. I asked my provider, they haven't responded yet. I'll update this post when it's set.
Backups. Nothing automated. SQLite files are small, easy to back up — I need a cron job that tars the database, the configs, and the certs, then ships to off-site storage. Next weekend's project.
Monitoring. Currently journalctl -u stalwart -f and hoping nothing breaks.
IP reputation. A fresh VPS IP has no reputation. Sending patterns matter: warm up slowly, don't blast 500 emails the first day. If you're running this for a real program, register with Gmail Postmaster Tools and Microsoft SNDS so you can see your sender score.
First real campaign. The infrastructure works, the templates work, the test sends work. I haven't run a real campaign against actual users yet. That's the next milestone, and it'll be the real test of whether this was worth building versus just paying for KnowBe4.
I'll come back and edit this section as each of these gets resolved. If you read this in 2027 and the post hasn't been updated since August 2026, assume the project stalled — or better, ping me and ask what happened.
The Ethical Bit
I keep coming back to this because it's easy to forget when you're neck-deep in JSON config files.
Phishing simulation only works if the targets consented. In an internal corporate program, that's "your employer told you phishing tests happen, and you agreed when you signed the acceptable-use policy." In any other context — public users, friends, strangers on the internet — it's not a simulation, it's a phishing attack.
GoPhish is a neutral tool. It can be used to train people, and it can be used to harm them. Same for Stalwart. Same for every open-source tool in this stack. Build the awareness program, get explicit buy-in from whoever you're testing, and document the consent. The industry has enough bad actors already.
How I Built This
A note on process, since I think it matters for trust.
The commands and configs in this post are mine. The architectural decisions are mine. The debugging was mine — many evenings of mine. But when I got stuck, I bounced questions off an AI assistant (via Cherry Studio). It pointed me at stalwart-cli when I was stuck on the listener port issue, suggested the custom DNS provider bridge approach when Spaceship wasn't on the native list, and helped me reason about why Gmail was rejecting mail without PTR.
Mentioning this because I think it matters for trust. If you read a tutorial and the author claims to have hand-crafted every character of every config file, that's either a lie or a sign they had a very long weekend. Most of us use whatever tools work.
What's Next
This is part one. I'll update this post (or write a follow-up) when:
PTR record is set, and we can measure whether Gmail stops flagging mail as spam
The backup cron job is in place and has survived at least one weekly run
The first real phishing campaign goes out and I can share what worked and what flopped
The build itself is repeatable. The interesting parts are what happens after.