home

3 min read

Cloudflare Tunnel Health Checks using Docker Compose

Cloudflare Tunnel and Docker Compose work great together for hosting applications without exposing ports directly to the Internet. Sometimes we need services to wait until cloudflared is connected and ready to receive requests before starting, but how?

Health Checks in cloudflared with Kubernetes

The Cloudflare docs describe how to check for readiness when using Kubernetes, but not how to do it using Docker Compose. In Kubernetes, we can add a readiness probe like this:

This is great for Kubernetes, but what about Docker Compose?

Health Checks in Docker Compose

Compose supports health checks by running arbitrary commands within the container (service) to determine when it's ready. Other services in the Compose file can specify dependencies on other services to ensure they don't start until the dependent service is healthy.

Here's a good article showing how to wait to start a service until a mysql service is ready to receive connections:

services:
  web:
    image: alpine:latest
    depends_on:
      db:
        condition: service_healthy
  db:
    image: mysql:5.7
    ports:
      - "3306:3306"  # Exposes port 3306 from the container to port 3306 on the host
    environment:
      MYSQL_ROOT_PASSWORD: root
    healthcheck:
      test: ["CMD", "mysql", "-u", "root", "-proot", "--execute", "SHOW DATABASES;"]
      interval: 3s
      retries: 5
      timeout: 5s

credit: (source)

How Do We Health Check cloudflared with Docker Compose?

Now that we know how to create health checks in Compose, we need to figure out how to add one for cloudflared.

A quick Google search revealed multiple issues in the cloudflared repository that are nearly a year old where users are having difficulty adding health checks. There's even a PR from 10 months ago to add a health check command to cloudflared, but it looks like it's having difficulty landing.

Some users have shared workarounds, such as creating a custom Docker image that includes curl:

FROM tarampampam/curl as curl
FROM erisamoe/cloudflared
COPY --from=curl /bin/curl /bin/

With curl added to the cloudflared container, we're able to add a health check to the cloudflared service:

healthcheck:
  test: ['CMD-SHELL', 'curl -fsS http://localhost:2000/ready']
  interval: 1s
  timeout: 2s
  retries: 60

For my use-case, I didn't want to maintain a Docker image for cloudflared because it updates fairly regularly and feels like unnecessary complexity.

Separate Healthcheck Service

Instead of creating a custom Docker image, I opted to create a second service to do health checks:

services:
  gitea:
    image: gitea/gitea:1.22.3
    depends_on:
      cloudflare-tunnel-healthy:
        condition: service_healthy

  cloudflare-tunnel-healthy:
    image: quay.io/curl/curl:8.10.1
    init: true
    command: sleep infinity
    depends_on:
      - cloudflare-tunnel
    healthcheck:
      test: ['CMD-SHELL', 'curl -fsS http://cloudflare-tunnel:2000/ready']
      interval: 1s
      timeout: 2s
      retries: 60

  cloudflare-tunnel:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run --metrics=0.0.0.0:2000
    environment:
      - TUNNEL_TOKEN=${CF_TUNNEL_TOKEN}

This ensures that Gitea does not start until cloudflare-tunnel is healthy. I needed this for Gitea because there are other services in this Compose file that use docker images hosted by this Gitea instance and were failing to pull the image because Gitea wasn't running yet.

Here's what my script looks like for starting services:

# start Gitea first so we don't try to pull images from it before it's started:
docker compose up -d gitea

# Now start the rest of the containers
docker compose up -d

WIth the new health checks in place, there is no longer a race condition where Compose would try to pull images from Gitea before the Cloudflare Tunnel was connected. Success!

Conclusion

While Cloudflare Tunnel is one of the most useful tools I've ever used, it's unfortunate how difficult it was to figure out something as simple as health checks. Hopefully we're able to get a solution built directly into cloudflared soon. Until then, I'm happy with the workaround I came up with.