Self-hosting with Docker and Cloudflared: Minimizing Host OS Exposure

I’ve recently come to like the idea of “minimal host exposure” when self-hosting services. The service should be accessed through some kind of reverse proxy or tunnel, while the host OS (perhaps any modern Linux distribution) serves solely as a Docker host and remains as “hidden” as possible, with no direct access from the Internet.

I’m using Cloudflare Zero Trust as the tunnel.

Example: WebDAV server

Taking a WebDAV server as an example, we can see how a simple containerized self-hosted service can be set up. In this demo, the Docker Compose configuration files are placed under ~/srv/.

Note: this is only meant to demonstrate the overall idea, 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 token, etc., by yourself.

Cloudflare Tunnel

We should create a Cloudflare tunnel first if one does not already exist. The tunnel itself can also run inside a container.

Following Cloudflare’s documentation (Section 1: Create a tunnel, up to step 5), we can create a tunnel, 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

Executing the following command

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

brings up the container. This also creates a named Docker bridge network called cloudflared.

The WebDAV server container

# ~/srv/webdav/compose.yml
ervices:
  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 adds the webdav container to the external named network cloudflared that we just created. In this way, the cloudflared container can access the WebDAV server via container-to-container communication using <container_name>:<port>, e.g., webdav:6065.

Many self-hosted services require us to mount a data directory or connect to a database (e.g., PostgreSQL) to persist user data and server configuration. In this example, we organize the mapped data under ~/data/. The official documentation of the service may provide a sample configuration file, for example, ~/data/webdav/config.yml. We can define a list of users there. We then mount that configuration file into the container as shown in the compose.yml file above.

Using

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

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

So the WebDAV server listens on port 6065 by default. (In many cases, we may customize the port a service listens on, but here we keep the upstream default configuration as much as possible. It usually does not matter, since we are not mapping the port to the host.)

Publishing the application

Then go back to the Cloudflare Zero Trust dashboard and publish this application. Suppose we have a domain example.com managed by Cloudflare. We can publish an application as follows:

Note that in the URL field we enter the WebDAV server’s container name and service port. No need to use a bare IP address, which feels quite elegant.

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 port on the host OS firewall (or the VPS provider’s firewall). The host can be configured to be completely inaccessible from the outside (except for SSH, of course, if you need remote login 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 have self-hosted several services this way, e.g., Vaultwarden, Miniflux and Nginx (for static file/webpage hosting).

The trade-off is that the service now depends on Cloudflare’s infrastructure. While unlikely, Cloudflare has experienced several major outages in recent years. However, for hobby projects or home servers, I am not particularly concerned about that downtime.