visitor@0xll.sh:~$ cat blog/multi-tenant-phishing-server.md

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

  1. 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.
  2. 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.
  3. 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.
  4. 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:

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:

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

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.