DEV Community

Cover image for Exposing Development Environments with Cloudflare Tunnel and WARP Without Opening Ports
Ivan Firestone
Ivan Firestone

Posted on

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. What I didn't want was a server sitting on the Internet with ports 80 and 443 open, waiting to be scanned.

Those requirements pull in opposite directions: the easier the access, the bigger the exposure. This is the story of 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'll explain the design, the detail that breaks Laravel behind the proxy and, above all, the risks.

The Idea: Let the Server Call Out

The usual way to publish an app is to open a port and let the world connect in. The tunnel flips that:

Internet → Cloudflare (TLS) → tunnel (outbound connection) → Traefik → containers
Enter fullscreen mode Exit fullscreen mode

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.

That solves exposure and certificates in one move. But a tunnel only gets traffic to the door; something still has to decide which container answers.

Choosing the Router

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.

Putting the Pieces Together

The base is 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
Enter fullscreen mode Exit fullscreen mode

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.

From there, every new app just 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
Enter fullscreen mode Exit fullscreen mode

With that, an app is live over HTTPS. If it's a Laravel app, though, there's a catch.

The Detail That Breaks Laravel

Since TLS terminates at Cloudflare and Traefik talks HTTP to the app, Laravel sees HTTP requests. The typical symptoms: 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: '*');
})
Enter fullscreen mode Exit fullscreen mode

And in the service provider, force the scheme:

URL::forceScheme('https');
Enter fullscreen mode Exit fullscreen mode

It works. But look closely at that '*', because it carries a risk.

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.

Which leads to the question the whole design depends on: is the origin really closed?

Really Closing the Ports

There are 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.

The Last Piece: Reaching the Databases

Apps were public, ports were closed. But for debugging I still wanted to connect to the environment's databases, without exposing them to anyone.

That's where Cloudflare One WARP comes in. With 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

Does the same setup carry over to production? Not quite. 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.

What You Give Up

None of this is free:

  • 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.

What I Take Away

The server I wanted to keep hidden is hidden: nothing listens on the public Internet, and I still deploy frequently with HTTPS and reach the databases from my own machine.

If you take only a few things from this:

  • 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.

Top comments (0)