Skip to main content

Building a Secure Staging Environment You Don't Want to Expose

#coolify #cloudflare #wireguard #security #devops

TL;DR

Problem: Your staging environments on Hetzner/VPS shouldn’t be publicly accessible - they contain unfinished code, test data, and security vulnerabilities.

Solutions:

ApproachBest ForSetup Time
WireGuard Easy (via Coolify)Solo devs, small teams, max privacy~15 min
Cloudflare Zero TrustLarger teams, clients (no VPN app needed)~20 min
Both combinedSensitive projects, defense in depth~30 min

Key steps:

  1. Deploy WireGuard Easy as a Coolify one-click service, OR
  2. Create a Cloudflare Tunnel via Networks → Connectors → Cloudflare Tunnels
  3. Add Access policies to restrict who can reach your apps
  4. Don’t forget: Bypass auth for webhook paths (/api/webhooks/*) so Stripe/PayPal/GitHub can still reach your endpoints

Cost: Free (both options)


The Problem

You’re running staging environments on your VPS (maybe that sweet Hetzner setup). You need your team or clients to access them, but you absolutely don’t want these half-baked apps exposed to the internet where:

  • Search engines might index them
  • Attackers could find vulnerabilities in unfinished code
  • Sensitive test data could leak
  • Your CI/CD preview deployments are visible to anyone

The solution? Layer your access controls. This guide covers two approaches you can use independently or together.


Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                     Your Hetzner VPS                        │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │   Coolify   │  │  Staging    │  │   WireGuard Easy    │  │
│  │  (Manager)  │  │   Apps      │  │   (VPN Gateway)     │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
        ▼                     ▼                     ▼
   Option A:             Option B:             Option C:
   WireGuard         Cloudflare Zero            Both
   VPN Only            Trust Only            (Defense in Depth)

Option A: WireGuard Easy via Coolify

Best for: Small teams, maximum privacy, no third-party dependencies

Step 1: Deploy WireGuard Easy in Coolify

  1. Go to your Coolify dashboard
  2. Navigate to Projects → Select your project → New Resource
  3. Choose Service → Search for “WireGuard Easy”
  4. Click to deploy

Step 2: Configure Environment Variables

In the service settings, configure these essential variables:

# Your VPS public IP
WG_HOST=your-server-ip.com

# Admin UI password (change this!)
PASSWORD=your-secure-admin-password

# VPN subnet (default is fine for most cases)
WG_DEFAULT_ADDRESS=10.8.0.x

# DNS for VPN clients
WG_DEFAULT_DNS=1.1.1.1

# Port for WireGuard (UDP)
WG_PORT=51820

Step 3: Configure Networking

In Coolify, ensure:

  • Port 51820/udp is exposed for VPN traffic
  • Port 51821/tcp is exposed for the admin UI (or keep internal-only)

Important: If you only want the admin UI accessible via VPN:

# In your docker-compose override
ports:
  - "51820:51820/udp"  # VPN - must be public
  # Remove or comment out the web UI port
  # - "51821:51821/tcp"  # Admin UI - keep internal

Step 4: Create VPN Clients

  1. Access WireGuard Easy admin UI at http://your-server:51821
  2. Log in with your password
  3. Click ”+ New Client”
  4. Name it (e.g., “MacBook-Tuan”, “iPhone-Dev”)
  5. Download the config or scan the QR code

Step 5: Configure Staging Apps

Now configure your staging apps in Coolify to only bind to the VPN interface:

# In your staging app's docker-compose or Coolify settings
# Instead of exposing to 0.0.0.0, bind to VPN subnet
services:
  staging-app:
    ports:
      - "10.8.0.1:3000:3000"  # Only accessible via VPN

Or use Traefik with internal-only routing:

labels:
  - "traefik.http.routers.staging.rule=Host(`staging.internal`)"
  # No external entrypoint

Step 6: Connect and Test

  1. Install WireGuard client on your device
  2. Import the config file
  3. Connect to VPN
  4. Access your staging app at http://10.8.0.1:3000 or internal hostname

Option B: Cloudflare Zero Trust

Best for: Larger teams, SSO integration, no VPN client needed, audit logs

Step 1: Set Up Cloudflare Zero Trust

  1. Go to Cloudflare DashboardZero Trust
  2. Create your team name (e.g., yourcompany)
  3. Choose the Free plan (up to 50 users)

Step 2: Create a Cloudflare Tunnel

  1. In Zero Trust dashboard → NetworksConnectorsCloudflare Tunnels
  2. Click Create a tunnel
  3. Choose Cloudflared as the connector type
  4. Name it (e.g., staging-tunnel)
  5. Select Save tunnel
  6. Install cloudflared on your VPS using the command shown in the dashboard:
# The dashboard will show a command like this for your OS:
# For Debian/Ubuntu:
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
sudo cloudflared service install <YOUR_TOKEN>
  1. Once the connector appears as Healthy in the dashboard, select Next

Step 3: Configure Public Hostnames

In the dashboard, go to the Published applications tab:

  1. Enter a subdomain (e.g., staging-app)
  2. Select your domain from the dropdown
  3. Under Service, configure:
    • Type: HTTP
    • URL: localhost:3000 (your app’s internal port)
  4. Click Save

Repeat for additional apps:

SubdomainService TypeURL
staging-appHTTPlocalhost:3000
staging-apiHTTPlocalhost:8080
coolifyHTTPlocalhost:8000

Alternative: Local config file

For more control, create ~/.cloudflared/config.yml:

tunnel: YOUR_TUNNEL_ID
credentials-file: /root/.cloudflared/YOUR_TUNNEL_ID.json

ingress:
  # Staging app 1
  - hostname: staging-app.yourdomain.com
    service: http://localhost:3000
  
  # Staging app 2  
  - hostname: staging-api.yourdomain.com
    service: http://localhost:8080
  
  # Coolify dashboard (optional)
  - hostname: coolify.yourdomain.com
    service: http://localhost:8000
  
  # Catch-all (required)
  - service: http_status:404

Step 4: Verify Tunnel Status

If you used the dashboard installation command, the tunnel is already running as a service.

# Check tunnel status
sudo systemctl status cloudflared

# View logs
sudo journalctl -u cloudflared -f

For local config file users:

# Run manually (for testing)
cloudflared tunnel run staging-tunnel

# Or install as a service
sudo cloudflared service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared

Step 5: DNS Records (Automatic)

When you add a public hostname in the dashboard, Cloudflare automatically creates the DNS CNAME record for you. No manual DNS configuration needed!

You can verify in your Cloudflare DNS settings:

staging-app    CNAME   <tunnel-id>.cfargotunnel.com (proxied)

Step 6: Add Access Policies

This is the key security layer:

  1. Go to Access controlsApplicationsAdd an application
  2. Choose Self-hosted
  3. Configure:
    • Application name: Staging Apps
    • Session duration: 24 hours
    • Subdomain: staging-app
    • Domain: Select your domain

Step 7: Bypass Auth for Webhook Paths

Webhooks from services like Stripe, PayPal, GitHub, etc. can’t authenticate through Zero Trust. You need to create a Bypass policy for these paths.

Option A: Separate Access Application for Webhooks

  1. Go to Access controlsApplicationsAdd an application

  2. Choose Self-hosted

  3. Configure:

    • Application name: Staging Webhooks
    • Subdomain: staging-app
    • Domain: Select your domain
    • Path: api/webhooks/* (or your webhook path)
  4. Create a policy:

    • Policy name: Allow Webhooks
    • Action: Bypass
    • Include: Everyone
  5. Important: Set this application’s policy order to be evaluated before your main protected application

Option B: Add Bypass Rule to Existing Application

  1. Edit your existing Access application
  2. Add an additional policy:
    • Policy name: Webhook Bypass
    • Action: Bypass
    • Include: Everyone
  3. In Assign path-specific policies, add:
    • Path: /api/webhooks/*
    • Policy: Webhook Bypass

Common webhook paths to bypass:

/api/webhooks/*
/api/stripe/webhook
/api/paypal/ipn
/webhooks/*
/.well-known/*          # For SSL verification, health checks
/health
/api/health

Security tip: Even with bypassed paths, validate webhook signatures in your application code:

// Example: Verify Stripe webhook signature
func handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
    payload, _ := io.ReadAll(r.Body)
    sig := r.Header.Get("Stripe-Signature")
    
    event, err := webhook.ConstructEvent(payload, sig, webhookSecret)
    if err != nil {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }
    // Process event...
}
  1. Add more hostnames if needed (staging-api, etc.)

  2. Create an Access Policy:

    Policy name: Team Access
    Action: Allow
    
    Include:
    - Emails ending in: @yourcompany.com
    
    Or for specific people:
    - Email: [email protected]
    - Email: [email protected]
  3. Choose authentication method:

    • One-time PIN (simplest - sends email code)
    • Google/GitHub/etc. (if you set up IdP integration)

Step 8: Test Access

  1. Open https://staging-app.yourdomain.com in browser
  2. You’ll see Cloudflare Access login page
  3. Enter your email → receive OTP → enter code
  4. Access granted! ✅

Test webhook bypass:

# Should return 200 (not redirect to login)
curl -I https://staging-app.yourdomain.com/api/webhooks/test

# Should redirect to Cloudflare Access login
curl -I https://staging-app.yourdomain.com/dashboard

Anyone not on your allow list sees a block page. Webhooks hit your app directly.


Option C: Defense in Depth (Both)

Best for: Maximum security, sensitive projects, compliance requirements

Combine both approaches:

Internet → Cloudflare Zero Trust (Auth Layer)

         Cloudflare Tunnel (Encrypted)

              Your VPS

         WireGuard (Network Isolation)

           Staging Apps (Internal Only)

Setup

  1. Deploy WireGuard Easy and configure staging apps to only listen on VPN subnet
  2. Set up Cloudflare Tunnel pointing to WireGuard’s internal addresses
  3. Add Zero Trust policies

This way:

  • Cloudflare handles authentication and DDoS protection
  • Even if someone bypasses Cloudflare, they can’t reach apps without VPN
  • Apps never directly touch the public internet

Quick Comparison

FeatureWireGuard EasyCloudflare Zero Trust
Setup complexityMediumMedium
Client requiredYes (WireGuard app)No (browser-based)
CostFreeFree (up to 50 users)
SSO integrationNoYes
Audit logsBasicDetailed
Works offlineYesNo
Third-party dependencyNoneCloudflare
Best forSmall teams, devsLarger teams, clients

Pro Tips

Tip 1: Webhook Paths Need Bypass

When testing integrations (Stripe, PayPal, GitHub Actions, etc.), remember your webhook endpoints need to bypass Zero Trust auth. Always:

  1. Create a Bypass policy for /api/webhooks/* or similar paths
  2. Validate signatures in your application code
  3. Use unique webhook secrets per environment (staging vs production)

Tip 2: Preview Deployments with Zero Trust

For PR preview environments in CI/CD:

# In your GitLab CI or GitHub Actions
deploy_preview:
  script:
    - coolify deploy --branch $CI_COMMIT_REF_NAME
    - cloudflared tunnel route dns staging-tunnel pr-${CI_MERGE_REQUEST_IID}.yourdomain.com

Tip 3: Time-Limited Access

In Cloudflare Zero Trust, create a separate policy for external reviewers:

Policy: External Review
Action: Allow
Include:
  - Email: [email protected]
Session Duration: 4 hours

Tip 4: Separate Networks per Environment

Production:  10.1.0.0/24  (different VPN or public)
Staging:     10.8.0.0/24  (WireGuard)
Development: 10.9.0.0/24  (Local only)

Tip 5: Monitor Access

WireGuard Easy shows connected clients in the UI. For Cloudflare:

  • InsightsLogsAccess shows all authentication attempts

Troubleshooting

WireGuard: Client can’t connect

  • Check UDP port 51820 is open in firewall: sudo ufw allow 51820/udp
  • Verify VPS allows UDP traffic (some providers block it)
  • Check client config has correct endpoint IP

Cloudflare: Tunnel not healthy

  • Check tunnel status in NetworksConnectorsCloudflare Tunnels
  • Status should be Healthy. If Inactive, Down, or Degraded:
    • Ensure cloudflared is running: systemctl status cloudflared
    • Check logs: journalctl -u cloudflared -f
    • Verify port 7844 outbound is not blocked by firewall

Access denied unexpectedly

  • Clear browser cookies for the domain
  • Check email is on the allow list (exact match required)
  • Verify session hasn’t expired

Conclusion

For your weekend projects and staging environments on that €10/month Hetzner stack:

  • Solo dev? WireGuard Easy is simple and self-contained
  • Working with clients? Cloudflare Zero Trust is more user-friendly (no VPN app needed)
  • Sensitive project? Use both for defense in depth

The best part? Both options integrate smoothly with Coolify and cost nothing extra.


Built with Coolify on Hetzner. Part of my “Weekend Projects Stack” series.