Securing Internal Services with VPN & Cloud Armor on GCP
When we deployed Supabase Studio for our client, one requirement was non-negotiable: the database admin interface should never be accessible from the public internet.
But we also needed the team to access it from anywhere — coffee shops, home offices, airports. The solution: a VPN combined with Cloud Armor IP whitelisting.
Here's how we built it.
The security model
Our approach has two layers:
- VPN: Team members connect through Outline VPN to get a known, static IP
- Cloud Armor: Only allows traffic from the VPN server's IP and GCP health checks
Developer laptop
│
▼ (VPN tunnel)
┌─────────────────┐
│ VPN Server │ (Static public IP: 34.x.x.x)
│ (Outline) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Cloud Armor │ (Whitelist: 34.x.x.x/32)
│ Security Policy│
└────────┬────────┘
│
▼
┌─────────────────┐
│ Load Balancer │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Supabase │
│ Studio │
└─────────────────┘
This gives us:
- Zero public exposure — Even with the URL, you can't access it without VPN
- Centralized access control — Revoke VPN access, revoke all access
- Audit trail — VPN logs show who connected when
- Works anywhere — Developers just need the Outline app
Part 1: Deploying Outline VPN
We chose Outline VPN for several reasons:
- Created by Jigsaw (Google) — battle-tested and secure
- Uses Shadowsocks protocol — harder to detect and block
- Cross-platform clients — macOS, Windows, Linux, iOS, Android
- Simple management — No complex configuration files
- Open source — Full transparency
The VPN component
Here's our Pulumi component for the VPN server:
export class Vpn extends pulumi.ComponentResource {
public readonly instance: gcp.compute.Instance;
public readonly staticIp: gcp.compute.Address;
public readonly persistentDisk: gcp.compute.Disk;
public readonly publicIp: pulumi.Output<string>;
constructor(name: string, args: VpnArgs, opts?: pulumi.ComponentResourceOptions) {
super("acme:components:Vpn", name, {}, opts);
// Static IP that won't change
this.staticIp = new gcp.compute.Address(
`${args.name}-vpn-ip`,
{
name: `${args.name}-vpn-ip`,
region: args.region,
},
{ parent: this },
);
// Persistent disk for VPN state
this.persistentDisk = new gcp.compute.Disk(
`${args.name}-vpn-data`,
{
name: `${args.name}-vpn-data`,
zone: `${args.region}-a`,
size: 10,
type: "pd-ssd",
},
{ parent: this },
);
// The VM instance
this.instance = new gcp.compute.Instance(
`${args.name}-vpn`,
{
name: `${args.name}-vpn`,
machineType: "e2-small",
zone: `${args.region}-a`,
tags: ["vpn-server", "allow-ssh"],
bootDisk: {
initializeParams: {
image: "projects/cos-cloud/global/images/family/cos-stable",
size: 20,
type: "pd-ssd",
},
},
attachedDisks: [
{
source: this.persistentDisk.selfLink,
deviceName: `${args.name}-vpn-data`,
},
],
networkInterfaces: [
{
network: args.network.id,
subnetwork: args.subnet.id,
accessConfigs: [
{
natIp: this.staticIp.address,
},
],
},
],
metadataStartupScript: startupScript,
},
{ parent: this },
);
this.publicIp = this.staticIp.address;
}
}Key design decisions:
Static IP
The VPN needs a stable public IP. If it changes, our Cloud Armor rules would break. We reserve a static IP and attach it to the instance.
Persistent disk
Outline stores its configuration and access keys on disk. If the VM restarts, we don't want to lose everything. A separate persistent disk survives instance recreation.
Container-Optimized OS
We use Google's Container-Optimized OS (COS). It's minimal, auto-updating, and designed for running Docker containers — perfect for Outline.
Small instance
e2-small (2 vCPU, 2 GB RAM) is plenty for a VPN server handling a small team. We can scale up if needed.
The startup script
The magic happens in the startup script. This runs when the VM boots:
#!/bin/bash
set -euo pipefail
# Configuration
SHADOWBOX_DIR="/mnt/disks/outline"
STATE_DIR="${SHADOWBOX_DIR}/persisted-state"
CONTAINER_NAME="shadowbox"
API_PORT="8443"
PUBLIC_HOSTNAME="${args.domain}"
SB_IMAGE="quay.io/outline/shadowbox:stable"
# Mount persistent disk
DATA_DISK="/dev/disk/by-id/google-${resourceName}-vpn-data"
MOUNT_POINT="/mnt/disks/outline"
if ! mountpoint -q "${MOUNT_POINT}"; then
# Format if new
if ! blkid "${DATA_DISK}" > /dev/null 2>&1; then
mkfs.ext4 -F "${DATA_DISK}"
fi
mkdir -p "${MOUNT_POINT}"
mount "${DATA_DISK}" "${MOUNT_POINT}"
fi
# Create state directory
mkdir -p "${STATE_DIR}"
# Generate API prefix (random string for management URL)
if [ ! -f "${STATE_DIR}/api_prefix" ]; then
head -c 16 /dev/urandom | base64 | tr '/+' '_-' > "${STATE_DIR}/api_prefix"
fi
SB_API_PREFIX=$(cat "${STATE_DIR}/api_prefix")
# Generate TLS certificate
if [ ! -f "${STATE_DIR}/shadowbox-selfsigned.crt" ]; then
openssl req -x509 -nodes -days 36500 -newkey rsa:4096 \
-subj "/CN=${PUBLIC_HOSTNAME}" \
-keyout "${STATE_DIR}/shadowbox-selfsigned.key" \
-out "${STATE_DIR}/shadowbox-selfsigned.crt"
fi
# Start Outline container
docker run -d \
--name "${CONTAINER_NAME}" \
--restart always \
--net host \
-v "${STATE_DIR}:${STATE_DIR}" \
-e "SB_STATE_DIR=${STATE_DIR}" \
-e "SB_API_PORT=${API_PORT}" \
-e "SB_API_PREFIX=${SB_API_PREFIX}" \
-e "SB_CERTIFICATE_FILE=${STATE_DIR}/shadowbox-selfsigned.crt" \
-e "SB_PRIVATE_KEY_FILE=${STATE_DIR}/shadowbox-selfsigned.key" \
"${SB_IMAGE}"Key points:
- Idempotent: Checks if things exist before creating them
- Persistent: All state is stored on the attached disk
- Self-healing: Container restarts automatically
- Secure: API prefix is random, TLS is always used
Firewall rules for VPN
The VPN server needs to accept connections:
this.firewallVpn = new gcp.compute.Firewall(
`${args.name}-allow-vpn`,
{
name: `${args.name}-allow-vpn`,
network: args.network.id,
allows: [
// Management API
{ protocol: "tcp", ports: ["8443"] },
// VPN connections (Outline uses high ports)
{ protocol: "tcp", ports: ["1024-65535"] },
{ protocol: "udp", ports: ["1024-65535"] },
],
sourceRanges: ["0.0.0.0/0"],
targetTags: ["vpn-server"],
},
{ parent: this },
);Why high ports?
Outline assigns a random high port for each access key. This makes the VPN traffic harder to fingerprint and block — useful if team members travel to countries with VPN restrictions.
Part 2: Cloud Armor security policy
Cloud Armor is GCP's web application firewall. We use it as an IP whitelist.
The security policy
const securityPolicy = new gcp.compute.SecurityPolicy(`${resourceName}-security-policy`, {
name: `${resourceName}-security-policy`,
description: "Allow only VPN and health check traffic",
rules: [
// Default: deny all
{
action: "deny(403)",
priority: 2147483647,
match: {
versionedExpr: "SRC_IPS_V1",
config: {
srcIpRanges: ["*"],
},
},
description: "Default deny all",
},
// Allow GCP health checks
{
action: "allow",
priority: 1000,
match: {
versionedExpr: "SRC_IPS_V1",
config: {
srcIpRanges: ["35.191.0.0/16", "130.211.0.0/22", "2600:2d00:1:b029::/64", "2600:2d00:1:1::/64"],
},
},
description: "Allow GCP health check probes",
},
// Allow VPN server
{
action: "allow",
priority: 900,
match: {
versionedExpr: "SRC_IPS_V1",
config: {
srcIpRanges: [pulumi.interpolate`${vpnPublicIp}/32`],
},
},
description: "Allow VPN server",
},
],
});Rule priorities
Lower numbers = higher priority. Our logic:
- Priority 900: Allow VPN server IP
- Priority 1000: Allow GCP health checks
- Priority 2147483647: Deny everything else (default rule)
Why allow health checks?
GCP load balancers use health checks to determine if backends are healthy. Without allowing these IPs, the load balancer thinks all instances are down, and nothing works.
The health check ranges are:
35.191.0.0/16— IPv4130.211.0.0/22— IPv42600:2d00:1:b029::/64— IPv62600:2d00:1:1::/64— IPv6
Attaching to the backend service
The security policy attaches to the backend service:
this.backendService = new gcp.compute.BackendService(`${resourceName}-backend`, {
name: `${resourceName}-backend`,
protocol: "HTTP",
portName: "http",
healthChecks: this.healthCheck.id,
securityPolicy: securityPolicy?.selfLink, // <-- Here
backends: [
{
group: this.instanceGroupManager.instanceGroup,
balancingMode: "UTILIZATION",
capacityScaler: 1.0,
},
],
});VPN client firewall rules
We also need firewall rules inside the VPC to allow VPN clients to reach internal resources:
// Allow VPN clients to access internal services
this.firewallVpnClients = new gcp.compute.Firewall(
`${resourceName}-allow-vpn-clients`,
{
name: `${resourceName}-allow-vpn-clients`,
network: this.vpc.id,
allows: [{ protocol: "tcp", ports: ["0-65535"] }, { protocol: "udp", ports: ["0-65535"] }, { protocol: "icmp" }],
sourceRanges: ["10.8.0.0/24"], // VPN client CIDR
priority: 900,
description: "Allow VPN clients to access internal services",
},
{ parent: this },
);The VPN client CIDR (10.8.0.0/24) is configured in Outline. When clients connect, they get an IP in this range. This firewall rule allows those IPs to access anything in the VPC.
Managing VPN access
Getting the management URL
After deployment, SSH into the VPN server:
gcloud compute ssh acme-dev-vpn --zone=europe-west2-aThen read the access configuration:
cat /mnt/disks/outline/access.txtYou'll get something like:
{
"apiUrl": "https://vpn.example.com:8443/a1b2c3d4e5f6",
"certSha256": "AB:CD:EF:12:34..."
}Using Outline Manager
- Download Outline Manager for your OS
- Click "Add Server"
- Paste the JSON configuration
- Create access keys for team members
Distributing access
Each team member gets their own access key:
- In Outline Manager, click "Add Key"
- Name it (e.g., "Sarah's MacBook")
- Share the access key (a
ss://URL) - Team member imports it into Outline Client
Revoking access
When someone leaves the team:
- Open Outline Manager
- Find their key
- Delete it
Access is revoked immediately. They can't connect to the VPN, so they can't access any internal services.
Additional hardening
SSH access control
Our VPN server has SSH enabled for management. We limit it:
this.firewallSsh = new gcp.compute.Firewall(`${resourceName}-allow-ssh`, {
name: `${resourceName}-allow-ssh`,
network: this.vpc.id,
allows: [{ protocol: "tcp", ports: ["22"] }],
sourceRanges: ["0.0.0.0/0"], // Consider restricting this
targetTags: ["allow-ssh"],
});For production, consider:
- Restricting to specific IPs
- Using Identity-Aware Proxy (IAP) for SSH
- Disabling SSH entirely after setup
OS-level firewall
Container-Optimized OS has a restrictive firewall by default. Our startup script adds rules:
# Allow VPN ports
iptables -A INPUT -p tcp --dport 8443 -j ACCEPT
iptables -A INPUT -p tcp --dport 1024:65535 -j ACCEPT
iptables -A INPUT -p udp --dport 1024:65535 -j ACCEPTAutomatic updates
COS updates automatically. For Outline, we run Watchtower:
docker run -d \
--name watchtower \
--restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower \
--cleanup --label-enable --interval 3600This checks for Outline updates hourly and applies them automatically.
Testing the setup
Verify Cloud Armor is blocking
Without VPN, try accessing the service:
curl -I https://db.dev.example.comExpected: HTTP/2 403 (Forbidden)
Verify VPN access works
- Connect to VPN via Outline Client
- Try again:
curl -I https://db.dev.example.comExpected: HTTP/2 200 (OK) or a redirect
Check Cloud Armor logs
In GCP Console, go to Network Security → Cloud Armor and view the logs. You'll see:
- Blocked requests with source IP and reason
- Allowed requests (VPN and health checks)
This is useful for debugging and auditing.
Gotchas and lessons learned
1. Health check IPs must be allowed
We initially denied all traffic and couldn't figure out why the load balancer showed all backends as unhealthy. The health check probes were being blocked.
2. VPN IP must be static
If you use an ephemeral IP and the VM restarts, the IP changes, and Cloud Armor blocks everything (including the new VPN IP). Always use a reserved static IP.
3. Outline uses random ports
Each access key gets a random port. That's why we allow the entire high port range (1024-65535). If you try to be more restrictive, some keys won't work.
4. IPv6 health checks
If your load balancer uses IPv6, you need to whitelist the IPv6 health check ranges too. We include both just in case.
5. Policy propagation takes time
After creating or updating a Cloud Armor policy, give it a few minutes to propagate. Testing immediately after might give misleading results.
Alternative approaches
IP-based access without VPN
If your team works from static IPs (offices), you could whitelist those directly:
srcIpRanges: [
"203.0.113.0/24", // London office
"198.51.100.0/24", // NYC office
],But this doesn't work for remote teams.
Identity-Aware Proxy (IAP)
GCP's IAP is another option. It verifies identity at the load balancer level:
- Works with Google accounts
- No VPN needed
- But: Requires browser-based access, doesn't work for all protocols
For Supabase Studio (a web app), IAP would work. We chose VPN because we also need access to the database directly for some tools.
Tailscale / WireGuard
Tailscale and WireGuard are excellent alternatives to Outline. We chose Outline for its simplicity and cross-platform support, but the same Cloud Armor approach works with any VPN.
The complete picture
Here's what we achieved:
✅ Zero public exposure — Services only accessible via VPN ✅ Simple access management — Add/remove via Outline Manager ✅ Works anywhere — Coffee shop, home, airport ✅ Audit trail — VPN logs show connection history ✅ Layered security — VPN + Cloud Armor + VPC firewall rules ✅ Low maintenance — Auto-updates for both OS and VPN
For internal tools and admin interfaces, this is our go-to security pattern.
Need help securing your internal infrastructure? Get in touch — we'll help you design a security architecture that protects without impeding productivity.
