TL;DR
Problem: Your staging environments on Hetzner/VPS shouldn’t be publicly accessible - they contain unfinished code, test data, and security vulnerabilities.
Solutions:
| Approach | Best For | Setup Time |
|---|---|---|
| WireGuard Easy (via Coolify) | Solo devs, small teams, max privacy | ~15 min |
| Cloudflare Zero Trust | Larger teams, clients (no VPN app needed) | ~20 min |
| Both combined | Sensitive projects, defense in depth | ~30 min |
Key steps:
- Deploy WireGuard Easy as a Coolify one-click service, OR
- Create a Cloudflare Tunnel via Networks → Connectors → Cloudflare Tunnels
- Add Access policies to restrict who can reach your apps
- 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
- Go to your Coolify dashboard
- Navigate to Projects → Select your project → New Resource
- Choose Service → Search for “WireGuard Easy”
- 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/udpis exposed for VPN traffic - Port
51821/tcpis 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
- Access WireGuard Easy admin UI at
http://your-server:51821 - Log in with your password
- Click ”+ New Client”
- Name it (e.g., “MacBook-Tuan”, “iPhone-Dev”)
- 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
- Install WireGuard client on your device
- Import the config file
- Connect to VPN
- Access your staging app at
http://10.8.0.1:3000or 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
- Go to Cloudflare Dashboard → Zero Trust
- Create your team name (e.g.,
yourcompany) - Choose the Free plan (up to 50 users)
Step 2: Create a Cloudflare Tunnel
- In Zero Trust dashboard → Networks → Connectors → Cloudflare Tunnels
- Click Create a tunnel
- Choose Cloudflared as the connector type
- Name it (e.g.,
staging-tunnel) - Select Save tunnel
- Install
cloudflaredon 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>
- 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:
- Enter a subdomain (e.g.,
staging-app) - Select your domain from the dropdown
- Under Service, configure:
- Type: HTTP
- URL:
localhost:3000(your app’s internal port)
- Click Save
Repeat for additional apps:
| Subdomain | Service Type | URL |
|---|---|---|
| staging-app | HTTP | localhost:3000 |
| staging-api | HTTP | localhost:8080 |
| coolify | HTTP | localhost: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:
- Go to Access controls → Applications → Add an application
- Choose Self-hosted
- 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
-
Go to Access controls → Applications → Add an application
-
Choose Self-hosted
-
Configure:
- Application name: Staging Webhooks
- Subdomain:
staging-app - Domain: Select your domain
- Path:
api/webhooks/*(or your webhook path)
-
Create a policy:
- Policy name: Allow Webhooks
- Action: Bypass
- Include: Everyone
-
Important: Set this application’s policy order to be evaluated before your main protected application
Option B: Add Bypass Rule to Existing Application
- Edit your existing Access application
- Add an additional policy:
- Policy name: Webhook Bypass
- Action: Bypass
- Include: Everyone
- In Assign path-specific policies, add:
- Path:
/api/webhooks/* - Policy: Webhook Bypass
- Path:
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...
}
-
Add more hostnames if needed (staging-api, etc.)
-
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] -
Choose authentication method:
- One-time PIN (simplest - sends email code)
- Google/GitHub/etc. (if you set up IdP integration)
Step 8: Test Access
- Open
https://staging-app.yourdomain.comin browser - You’ll see Cloudflare Access login page
- Enter your email → receive OTP → enter code
- 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
- Deploy WireGuard Easy and configure staging apps to only listen on VPN subnet
- Set up Cloudflare Tunnel pointing to WireGuard’s internal addresses
- 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
| Feature | WireGuard Easy | Cloudflare Zero Trust |
|---|---|---|
| Setup complexity | Medium | Medium |
| Client required | Yes (WireGuard app) | No (browser-based) |
| Cost | Free | Free (up to 50 users) |
| SSO integration | No | Yes |
| Audit logs | Basic | Detailed |
| Works offline | Yes | No |
| Third-party dependency | None | Cloudflare |
| Best for | Small teams, devs | Larger 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:
- Create a Bypass policy for
/api/webhooks/*or similar paths - Validate signatures in your application code
- 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:
- Insights → Logs → Access 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 Networks → Connectors → Cloudflare Tunnels
- Status should be Healthy. If Inactive, Down, or Degraded:
- Ensure
cloudflaredis running:systemctl status cloudflared - Check logs:
journalctl -u cloudflared -f - Verify port 7844 outbound is not blocked by firewall
- Ensure
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.