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:
- subdomain:
dav - domain:
example.com - type: HTTP
- URL:
webdav:6065
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.