Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

Exposing Development Environments with Cloudflare Tunnel and WARP Without Opening Ports

I needed a development environment with frequent deployments, public HTTPS URLs and convenient access to the databases for debugging, without leaving the server exposed to the Internet. The combination that worked for me

I needed a development environment with frequent deployments, public HTTPS URLs and convenient access to the databases for debugging, without leaving the server exposed to the Internet. The combination that worked for me:

  • Cloudflare Tunnel to publish the apps without opening ports 80 and 443.
  • Traefik as the internal router, with per-application labels.
  • Portainer to manage the containers.
  • Cloudflare One WARP to reach the private Docker network only from my machine.

I explain the design, the details needed to make Laravel work behind the proxy and, above all, the risks.

The Architecture

Internet → Cloudflare (TLS) → tunnel (outbound connection) → Traefik → containers

The key: the cloudflared connector opens the connection to Cloudflare, not the other way around. The server needs no inbound ports open. Cloudflare terminates TLS, and inside the server the traffic goes over HTTP to Traefik.

Why Traefik in Development?

I deploy very frequently. With Traefik, each application declares its routing with labels in its own docker-compose, and there's no central configuration to touch for every new project.

Before this I evaluated Dokploy for dev. It's convenient, but I found it heavy on resources for a small server. The path I chose (Portainer + Traefik + cloudflared) is lighter, at the price of writing the Traefik labels by hand. It's a trade-off of convenience against consumption.

The Base: A Stack with Traefik and the Tunnel

A shared network, Traefik without HTTPS (Cloudflare handles that) and cloudflared with the tunnel token:

services:
  traefik:
    image: traefik:v3
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks: [edge]

  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}
    networks: [edge]

networks:
  edge:
    name: edge

Note that no service publishes ports (ports:). That's intentional and it's the best defense: if no port is published, there's nothing you can expose by accident.

In the Cloudflare dashboard you configure the public hostname (for example, a wildcard under your domain) pointing to the service http://traefik:80.

Each app joins the same network and declares its routing:

services:
  myapp:
    image: myapp:dev
    networks: [edge]
    labels:
      - traefik.enable=true
      - traefik.http.routers.myapp.rule=Host(`myapp.dev.example.com`)
      - traefik.http.routers.myapp.entrypoints=web
      - traefik.http.services.myapp.loadbalancer.server.port=80

networks:
  edge:
    external: true

Laravel Behind the Proxy: The Detail That Causes Problems

Since TLS terminates at Cloudflare and Traefik talks HTTP to the app, Laravel sees HTTP requests. Typical result: URLs generated with http://, broken redirects and mixed content.

It's fixed by trusting the proxy's X-Forwarded-* headers and forcing HTTPS. In Laravel 11 or higher, in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})

And in the service provider, force the scheme:

URL::forceScheme('https');

The Important Warning

trustProxies(at: '*') means "I trust the headers from anyone". It's safe only if the origin is unreachable except through the tunnel. If someone can reach the container directly, they can forge X-Forwarded-For or X-Forwarded-Proto.

That's why it's worth combining three things:

  1. Don't publish the apps' ports.
  2. Close 80/443 in the firewall.
  3. Limit in Traefik which IPs are trusted for forwarded headers (forwardedHeaders.trustedIPs on the entrypoint), using the tunnel's internal network.

Really Closing the Ports

Layers, from most to least reliable:

  1. Don't publish ports in Docker. Without ports:, there's nothing to filter.
  2. Cloud provider firewall (network security rules): allow only what's strictly necessary, for example SSH from your IP.
  3. Host rules as additional defense. Beware: Docker manipulates iptables on its own and can bypass rules meant for higher-level firewalls; to filter traffic to published containers, the DOCKER-USER chain is the intended place.

Always verify from the outside with a port scan, not just by reading the configuration.

WARP: Private Access to the Docker Network

For debugging I want to connect to the environment's databases without exposing them. With Cloudflare One WARP and a private network route through the same tunnel, my machine reaches the Docker network as if it were inside. This allows:

  • Giving the databases a fixed IP on the internal network and connecting to them with my usual client.
  • Only authorized devices in the Zero Trust policy can access.
  • No database port is opened to the outside.

Production Is a Different Decision

In production I use Portainer without Traefik: the tunnel points directly to each application's container. Fewer pieces, less surface and fewer things that can fail. Traefik solves a deployment speed problem for me in development; in production, where changes are less frequent, that problem doesn't exist.

Trade-offs and Risks

  • Dependency on Cloudflare. If the service has an outage, your environments are unreachable. For development it's tolerable; for production it needs evaluating.
  • The tunnel token is a secret. Whoever has it can run a connector. Store it as a secret, not in the repository.
  • The Docker socket. Traefik mounts it read-only, but it's still sensitive access. There are alternatives with a socket proxy that limits the allowed operations.
  • Hand-written labels. It's more work per project than an integrated dashboard.
  • WARP maintenance. You have to manage device enrollment and access policies.
  • Pinned versions. Avoid latest for cloudflared and Traefik if you want reproducible deployments.

Conclusion

  • An outbound tunnel removes the need to open ports and simplifies TLS.
  • Not publishing ports in Docker is the strongest and cheapest defense.
  • trustProxies(at: '*') is only safe if the origin is unreachable by any other route.
  • Traefik speeds up development; in production, fewer pieces are usually better.
  • Verify exposure from the outside, not just in the configuration.
📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.