Self-hosting with Docker and Cloudflared: Minimizing Host Exposure

I’ve recently come to like the idea of minimizing host exposure when self-hosting services. Services are accessed through a reverse proxy or tunnel, while the host OS (may be any modern Linux distribution) acts only as a Docker host and remains inaccessible from the public Internet.

I use Cloudflare Tunnel for this.

Example: WebDAV server

Using a WebDAV server as an example, I show how to set up a simple containerized self-hosted service. In this demo, the Docker Compose configuration files are stored under ~/srv/.

Note: this is only meant to demonstrate the overall setup, not to serve as a step-by-step tutorial covering every command and file edit. You may need to adapt the configuration files, copy the tunnel token, and make other small changes yourself.

Cloudflare Tunnel

First, create a Cloudflare Tunnel if you don’t already have one. The tunnel itself can also run inside a container.

Follow Cloudflare’s documentation (Section 1: Create a tunnel, up to step 5) to create a tunnel. Then copy the token and save it to ~/srv/cloudflared/.env. Make sure that no untrusted users can access this file.

# ~/srv/cloudflared/.env
TUNNEL_TOKEN=eyJh...

Then create the Docker Compose configuration file at ~/srv/cloudflared/compose.yml:

# ~/srv/cloudflared/compose.yml
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}
    networks:
      - cloudflared

networks:
  cloudflared:
    name: cloudflared
    driver: bridge

Run the following command to start the container:

# cd ~/srv/cloudflared
docker compose up -d

This also creates a named Docker bridge network called cloudflared.

The WebDAV server container

Next, create ~/srv/webdav/compose.yml:

# ~/srv/webdav/compose.yml
services:
  webdav:
    # A simple and standalone WebDAV server.
    image: ghcr.io/hacdias/webdav
    container_name: webdav
    restart: unless-stopped
    user: "1000:1000"
    volumes:
      - ${HOME}/data/webdav/config.yml:/config.yml:ro
      - ${HOME}/data/webdav/data:/data
    command: -c /config.yml
    networks:
      - cloudflared

networks:
  cloudflared:
    external: true

This configuration connects the webdav container to the external cloudflared network created above. The cloudflared container can then access the WebDAV server over the Docker network using <container_name>:<port>, for example, webdav:6065.

Many self-hosted services need a mounted data directory or a database such as PostgreSQL to persist user data and server configuration. In this example, the mounted data is stored under ~/data/. The service documentation may provide a sample configuration file; for WebDAV, see ~/data/webdav/config.yml. We can define a list of users there and then mount the configuration file into the container as shown in the compose.yml file above.

Run:

# cd ~/srv/webdav
docker compose up -d
docker ps

This may produce output like this:

CONTAINER ID   IMAGE                   COMMAND                 CREATED        STATUS       PORTS     NAMES
abcdefghijkl   ghcr.io/hacdias/webdav  "webdav -c /config.y…"  2 seconds ago  Up 1 second  6065/tcp  webdav
...

The WebDAV server listens on port 6065 by default. We could change the port, but there is little reason to do so here because it is not exposed on the host.

Publishing the application

Next, return to the Cloudflare Zero Trust dashboard and publish the application. Suppose we have a domain, example.com, managed by Cloudflare. We can publish the application as follows:

Note that the URL uses the WebDAV server’s container name and service port. There is no need to use an IP address, which keeps the setup pleasantly simple.

The WebDAV service can then be accessed at https://dav.example.com.

Benefits

cloudflared establishes outbound-only connections to Cloudflare’s global network. This means we do not need to open any inbound ports for these services on the host OS firewall or the VPS provider’s firewall. The host can remain inaccessible from the public Internet, except for SSH if remote login is needed for maintenance. The host OS does not even need a public IP address.

We also do not need to worry about TLS certificates, since Cloudflare manages them. By applying Cloudflare WAF rules, we can add fine-grained control over who can access our services.

This feels safe and convenient.

I use this setup for several self-hosted services, including Vaultwarden, Miniflux, and Nginx for hosting static files and web pages.

The trade-off is that these services now depend on Cloudflare’s infrastructure. Cloudflare outages are uncommon, but they do happen; Cloudflare has experienced several major outages in recent years. For hobby projects and home servers, however, I’m comfortable with that trade-off.