One VPS, Many Operators: Building a Multi-Tenant Phishing Server for Red Team Engagements
How I run per-operator GoPhish instances, OAuth2 SMTP relays, and a WireGuard-only admin surface from a single box — and why everything else stays off the internet.
Every red team engagement with more than one operator eventually hits the same wall: the shared phishing server. One GoPhish instance means one sending profile, one admin session getting logged out, one campaign history where you can't tell whose clicks are whose, and one person's misconfigured sending profile burning the whole team's email reputation at 2 AM.
The usual fix — spin up a separate VPS per operator — is fine until you're paying for (and hardening, and tearing down) five boxes per engagement. So I built a middle ground: a single VPS that hosts fully isolated per-operator stacks, where the only thing exposed to the internet is a WireGuard port. Everything else — admin UIs, sending relays, campaign data — lives on internal Docker networks that no operator can even route to.
This post walks through the design and the pieces. The whole thing is a handful of files: a docker-compose.yml, a WireGuard provisioning script, and per-operator configs.
The design goals
- Operator isolation. Alice and Bob each get their own GoPhish instance, their own SMTP relay, their own credentials, and their own sending reputation. If Bob's campaign gets his mailbox throttled, Alice keeps sending.
- Zero public attack surface. GoPhish admin panels on the public internet are a warstory magnet. Shodan finds them, brute-force bots love them, and a leaked admin session is game over. The admin surface here is reachable only through the VPN tunnel.
- Clean identity separation. Each operator sends through their own OAuth2-authenticated relay under their own lookalike-domain mailbox, so SPF/DKIM/DMARC actually align instead of relying on display-name spoofing.
- Fast spin-up, fast burn-down. One command to provision a new operator, one command to revoke them at engagement end.
The topology
INTERNET
│
UDP 51820 (WireGuard)
│
┌───────────────┴───────────────┐
│ VPS (single docker host) │
│ │
alice@laptop ───────▶│ wireguard (172.28.0.1) │◀─────── bob@laptop
(wg tunnel) │ │ │ (wg tunnel)
│ caddy :8443 │
│ /alice → 172.28.0.11:3333 │
│ /bob → 172.28.0.12:3333 │
│ │
│ net_alice (172.28.1.0/24): │
│ gophish-alice .11 │
│ oauth-relay-alice .20 │
│ │
│ net_bob (172.28.2.0/24): │
│ gophish-bob .11 │
│ oauth-relay-bob .20 │
└───────────────────────────────┘
Nothing is published except UDP 51820. No port mappings for GoPhish, no port mappings for the relays. The operators connect over WireGuard and reach their (and only their) stack by internal IP.
Piece 1: Per-operator GoPhish stacks in Compose
Each operator gets a gophish service and a relay service on a dedicated internal network:
gophish-alice:
image: gophish/gophish:latest
volumes:
- ./operators/alice/gophish:/gophish-config:ro
networks:
net_alice:
ipv4_address: 172.28.1.11
oauth-relay-alice:
build: ./relay
env_file: ./operators/alice/relay.env
networks:
net_alice:
ipv4_address: 172.28.1.20
The networks (net_alice, net_bob) are internal Docker bridges with no published ports. Alice's GoPhish can talk to Alice's relay on oauth-relay-alice:2525 and to nothing else. The operator configs are mounted read-only, and each gets its own config.json with a distinct admin password and mail profile.
In GoPhish, the sending profile is simply:
- Host:
oauth-relay-alice:2525 - Auth: PLAIN, per-operator relay credentials
- From: the operator's assigned mailbox on the lookalike domain
Piece 2: Sending through OAuth2 instead of trusting relay passwords
Classic red team relays either smash mail out unauthenticated (instant spam-folder) or use a catch-all SMTP cred (instant suspension). The more durable pattern is client-credentials OAuth2 against a real M365 tenant: the relay authenticates with XOAUTH2, gets a scoped Mail.Send token, and sends as a legitimate mailbox with full SPF/DKIM/DMARC alignment.
The relay is ~150 lines of Python: a minimal SMTP server on 2525, a token cache keyed by mailbox, and per-message XOAUTH2 acquisition from Microsoft's token endpoint. It speaks both AUTH XOAUTH2 and AUTH OAUTHBEARER and passes SASL over to the upstream M365 host. Two lookalike-domain mailboxes (e.g., it-ops@yourlookalike.tld and security@yourlookalike.tld) map to the two pretext senders, and each operator's relay config holds only their own tenant/app/secret.
The payoff: mail that authenticates end-to-end instead of relying on display-name trickery, and per-operator blast-radius if one mailbox gets flagged.
Piece 3: The admin surface is VPN-only
Every GoPhish admin UI in this design binds to its internal bridge — it has no path to the internet, even misconfigured. Operators reach theirs over WireGuard, either directly (http://172.28.0.11:3333) or through a small Caddy proxy for sane TLS and path routing:
https://<vps-tunnel-ip>:8443/alice/ → gophish-alice:3333
https://<vps-tunnel-ip>:8443/bob/ → gophish-bob:3333
Caddy listens on the WireGuard interface only, so even the reverse proxy is unreachable without a peer key. Add basic auth in front of that and you have two independent factors on the admin plane before GoPhish's own login ever comes into play.
Piece 4: Peer provisioning as a one-liner
The glue is a small script with four verbs:
./wg-setup.sh init # generate server keypair + wg0.conf
./wg-setup.sh add-peer alice # keygen, allocate tunnel IP, write peer conf
./wg-setup.sh list-peers
./wg-setup.sh revoke-peer alice # drop the peer, apply with wg syncconf
Details that matter more than they look:
- Peers get
AllowedIPsscoped to the admin network only (e.g.,172.28.0.0/24). A compromised operator laptop can reach the admin plane; it cannot pivot through your VPS to anything else, because the tunnel simply won't carry those routes. - IP allocation is stateful — the script greps existing peer configs for used tunnel IPs and hands out the next free one, so
add-peeris idempotent across runs. - Revocation is real revocation.
revoke-peerremoves the peer block and syncs the running interface; the operator's config dies instantly, no container restarts, no disruption to other operators.
Piece 5: Run it like a service
A tiny systemd unit (docker compose up -d on boot, down on stop) plus a one-line enable and the box survives reboots without anyone remembering how it was deployed. Tear-down at engagement end is docker compose down + revoke-peer for each operator, and the VPS returns to a single closed UDP port.
What I'd do differently next round
- Per-operator TLS with client certs on the Caddy front instead of (or alongside) basic auth — mTLS is a natural fit when every operator already holds a WireGuard key.
- Metrics without exposure. Campaign click/submit stats are tempting to expose via a dashboard; a read-only API token scraped over the tunnel by an internal collector is the safer shape.
- Evilginx2 belongs on its own box. The aware-proxy variant wants raw ports 80/443 and its own dedicated domain reputation. Co-locating it with your sending infrastructure couples two very different burn-down profiles. In this build it's deliberately a separate deployment.
Closing thoughts
None of the individual pieces here are novel — WireGuard, Docker networks, and OAuth2 relays are all well-trodden. The value is in the composition: treating phishing infrastructure like a product with multi-tenancy, identity boundaries, and a real revocation story, instead of a shared VM with a sticky note for the admin password.
The result: one VPS, one closed UDP port, N isolated operators, and an engagement-end teardown that takes a minute.
This write-up describes defensive-safety tooling used in authorized red team engagements. If you operate phishing simulations for your organization, everything here maps directly to authorized awareness-testing tooling — the isolation and revocation properties are exactly what a security team should demand from that tooling too.