Expose Your Home Lab Securely: Cloudflare Tunnel + Nginx Proxy Manager (Docker Guide)

Publishing self-hosted apps on the internet without opening ports is now simple and safe with Cloudflare Tunnel and Nginx Proxy Manager (NPM). This guide shows you how to route traffic from your domain through Cloudflare’s global network to your internal services, using Docker on Linux. You will get HTTPS by default, Zero Trust access, and simple management for multiple apps.

Why use Cloudflare Tunnel + Nginx Proxy Manager?

Cloudflare Tunnel (cloudflared) creates an outbound-only connection from your server to Cloudflare, so you do not need port forwarding or a public IP. Nginx Proxy Manager provides a friendly UI to reverse-proxy multiple services, manage SSL, and handle redirects and headers. Together, they offer a secure and flexible edge-to-origin pipeline for home labs and small businesses.

Prerequisites

- A domain managed by Cloudflare (nameservers must point to Cloudflare)
- A Linux server (Ubuntu 22.04+ recommended) with Docker and Docker Compose installed
- Basic familiarity with terminal and Docker

Architecture overview

Cloudflare edge terminates TLS and forwards requests over an encrypted tunnel to the cloudflared container on your server. The cloudflared service forwards hostnames to Nginx Proxy Manager, which then routes requests to internal applications (e.g., Home Assistant, Portainer, Jellyfin) based on hostnames. This design centralizes access, certificates, logging, and security rules.

Step 1 — Install Docker and Compose (Ubuntu)

Run the following commands to set up Docker:

sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
newgrp docker

Step 2 — Create a Cloudflare Tunnel

1) In the Cloudflare dashboard, open Zero Trust (or Tunnels) and create a new Tunnel named “homelab”.
2) Choose “Docker” as the environment and copy the token-based command or credentials JSON for cloudflared. We will use the token method for simplicity.
3) Do not add routes yet—we will define them in the docker-compose file.

Step 3 — Create docker-compose.yml

Create a working directory (e.g., /opt/homelab) and add this compose file. Replace example.com with your domain and paste your Cloudflare tunnel token.

version: "3.8"
services:
  cloudflared:
    image: cloudflare/cloudflared:2024.8.3
    command: tunnel run
    environment:
      - TUNNEL_TOKEN=<PASTE_YOUR_TUNNEL_TOKEN>
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "cloudflared", "version"]
      interval: 30s
      timeout: 10s
      retries: 3

  npm:
    image: jc21/nginx-proxy-manager:latest
    restart: unless-stopped
    ports:
      - "81:81"        # NPM admin UI
      - "80:80"        # HTTP
      - "443:443"      # HTTPS
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    depends_on:
      - cloudflared

networks:
  default:
    name: homelab

Start the stack:

docker compose up -d
docker compose logs -f cloudflared

Step 4 — Map hostnames to Nginx Proxy Manager

Back in the Cloudflare dashboard, open your Tunnel and add public hostnames to route traffic to NPM. For each app, create a route like this:

Example routes:
- app1.example.com → http://npm:80
- media.example.com → http://npm:80
- portainer.example.com → http://npm:80

This means Cloudflare forwards incoming requests for those hostnames down the tunnel to the NPM container.

Step 5 — Configure Nginx Proxy Manager

1) Open http://YOUR_SERVER_IP:81 and log in (default admin credentials are shown on first boot; change them immediately).
2) For each internal service, create a “Proxy Host”:
- Domain Names: app1.example.com
- Scheme: http
- Forward Hostname/IP: the internal container name or IP (e.g., homeassistant or 127.0.0.1)
- Forward Port: the app port (e.g., 8123)
- Block Common Exploits: enabled
- Websockets Support: enabled (for apps like Home Assistant, Portainer, or anything real-time)

3) Under the SSL tab, select “Request a new SSL Certificate” and choose Let’s Encrypt. Enable “Force SSL” and “HTTP/2 Support”. NPM will fetch and auto-renew certificates for the hostname.

Step 6 — Enforce Zero Trust access (optional but recommended)

Use Cloudflare Access to protect sensitive apps with identity-aware policies. In Zero Trust → Access → Applications, add an app for app1.example.com, choose “Self-hosted”, and require login with your IdP (Google, GitHub, Microsoft, etc.). You can restrict by email domain, group, or country. Access will challenge users at the edge before requests hit your tunnel.

Step 7 — Security hardening tips

- In Cloudflare DNS, keep your A/AAAA records orange-cloud (proxied).
- Enable WAF and Bot Fight Mode where appropriate.
- For APIs or admin panels, add Access policies and IP allowlists in Cloudflare, and enable “Block Common Exploits” in NPM.
- If you need end-to-end encryption to NPM, set up a trusted origin certificate from Cloudflare and configure NPM to use HTTPS upstreams.

Troubleshooting

502 Bad Gateway: Check the NPM “Forward Hostname/IP” and port. Ensure the target app is reachable from the NPM container network.

403 from Cloudflare Access: Confirm your email is allowed by the Access policy and that your device clock is correct.

WebSockets not working: Enable “Websockets Support” in NPM and verify the app uses the correct path. Most real-time dashboards require this.

Large uploads fail: In NPM, add a Custom Nginx config snippet such as client_max_body_size 100m; for the specific host.

SSL mismatch or loops: Use HTTP between Cloudflare and NPM if Cloudflare terminates TLS at the edge. If you enable origin TLS, make sure certificates and trust are configured properly.

Scaling and operations

- Add more hostnames in the Tunnel as you publish new services; just point them to http://npm:80 and configure the upstream in NPM.
- Use multiple cloudflared instances (on different servers) in the same Tunnel for high availability; Cloudflare will load-balance them.
- Monitor with docker compose logs -f and Cloudflare analytics. Schedule updates with watchtower or perform manual rolling updates.

Wrap-up

You have exposed internal services securely on your own domain without opening any inbound ports. Cloudflare Tunnel handles the edge and connectivity, while Nginx Proxy Manager gives you a clean UI for reverse proxy rules, SSL, and performance tweaks. With Access policies, WAF, and good Nginx hygiene, you can safely run public-facing apps from a single Docker host.

Deploy a Private WireGuard VPN with Docker Compose (QR Codes for Mobile)

Why this guide

WireGuard is a modern VPN that is fast, secure, and simple to manage. Running it in Docker keeps your host clean, makes upgrades trivial, and allows you to back up your configuration as plain files. In this tutorial, you will deploy a production-ready WireGuard VPN with Docker Compose on an Ubuntu server, generate QR codes for easy mobile onboarding, and enable best-practice settings like IPv6 forwarding and DNS control.

Prerequisites

You need an Ubuntu 22.04/24.04 host (cloud VM or home server), a public DNS name for the server (e.g., vpn.example.com), and permission to forward UDP port 51820 on your router if you are behind NAT. You will also need a non-root user with sudo privileges. Windows or macOS clients can connect too, but we will demonstrate mobile setup using QR codes as it is the quickest way to get started.

Step 1 — Install Docker and Compose Plugin

Update your host, install Docker, and add your user to the docker group so you can run it without sudo.

sudo apt update && sudo apt -y upgrade
sudo apt -y install docker.io docker-compose-plugin
sudo systemctl enable --now docker
sudo usermod -aG docker $USER
# Re-log or run: newgrp docker

Step 2 — Create the project structure

We will store compose files in /srv/wireguard and persist configuration in /srv/wireguard/config. The container will keep keys and peer files under this directory, which makes backups easy.

sudo mkdir -p /srv/wireguard/config
sudo chown -R $USER:$USER /srv/wireguard

Step 3 — Write docker-compose.yml

Create a Compose file that uses the well-maintained LinuxServer.io WireGuard image. Replace vpn.example.com with your DNS name and adjust timezone and peer names to your needs.

nano /srv/wireguard/docker-compose.yml

version: "3.8"
services:
  wireguard:
    image: lscr.io/linuxserver/wireguard:latest
    container_name: wireguard
    cap_add:
     - NET_ADMIN
     - SYS_MODULE
    ports:
     - 51820:51820/udp
    volumes:
     - ./config:/config
     - /lib/modules:/lib/modules:ro
    environment:
     - PUID=1000
     - PGID=1000
     - TZ=Etc/UTC
     - SERVERURL=vpn.example.com
     - SERVERPORT=51820
     - PEERS=phone,laptop
     - PEERDNS=1.1.1.1
     - INTERNAL_SUBNET=10.13.13.0
     - ALLOWEDIPS=0.0.0.0/0,::/0
    sysctls:
     - net.ipv4.conf.all.src_valid_mark=1
     - net.ipv4.ip_forward=1
     - net.ipv6.conf.all.forwarding=1
    restart: unless-stopped

A few notes: PEERS seeds the initial clients; you can add more later. ALLOWEDIPS controls routing. With 0.0.0.0/0,::/0 the client routes all traffic through the VPN (full tunnel). For a split tunnel to only reach the VPN subnet, set 10.13.13.0/24 (and optionally fd00:13:13::/64 if you use IPv6).

Step 4 — Open the firewall and forward the port

If UFW is enabled on the host, allow UDP 51820. Also forward UDP 51820 on your router to the server’s LAN IP. If you use a cloud VM, open the UDP port in your provider’s security group.

sudo ufw allow 51820/udp

Step 5 — Start the stack

Bring the container up and follow logs. On the first start, it creates server keys and peer files under config/.

cd /srv/wireguard
docker compose up -d
docker compose logs -f

Step 6 — Get peer configs and QR codes

The image includes helper scripts. To display a peer config and its QR code, run:

docker exec -it wireguard /app/show-peer phone

Install the WireGuard app on iOS or Android, tap the plus button, choose “Scan from QR code,” and scan the code from your terminal. For Windows/macOS/Linux clients, copy the text config printed by the command above into a file like phone.conf and import it in the WireGuard desktop app.

To add a new peer at any time, use:

docker exec -it wireguard /app/add-peer tablet

Step 7 — Verify the connection

Activate the tunnel on your device. From the server, confirm the handshake:

docker exec wireguard wg show

You should see latest handshake times and transfer counters increase as you pass traffic. From the client, visit https://ifconfig.io to confirm your public IP matches the server and that DNS resolves as expected.

Optional: Tune routing, MTU, and DNS

If you only want to reach resources on your home network and keep general browsing on the local internet, change ALLOWEDIPS in the peer config to the private ranges you care about (for example, 10.13.13.0/24,192.168.1.0/24). For mobile networks with strict NAT, enable a keepalive in the peer config by adding PersistentKeepalive = 25. If you notice slow speeds, set MTU = 1280 in the peer config to avoid fragmentation on cellular carriers.

For ad blocking, set PEERDNS to your Pi-hole or AdGuard Home address reachable through the tunnel, e.g., 10.13.13.2. You can also use privacy resolvers like 1.1.1.1 or 9.9.9.9.

Backups and updates

The critical state lives in /srv/wireguard/config. Back it up regularly with your favorite tool (rsync, Restic, Borg). To upgrade safely, pull the new image and recreate the container; your config remains intact.

cd /srv/wireguard
docker compose pull
docker compose up -d

Troubleshooting

No handshake? Verify the UDP port forward and make sure your DNS record points to the right public IP. On mobile networks behind Carrier Grade NAT, incoming connections may be blocked—host the server on a cloud VM or use a home ISP with a public IP. If the tunnel connects but no traffic flows, confirm IP forwarding is enabled (the Compose file includes sysctls) and that ALLOWEDIPS is correct on both ends. For double NAT routers, enable a full-cone/endpoint-independent NAT if available, or use an alternate UDP port like 51821.

You are done

With Docker Compose and WireGuard, you now have a lightweight, fast VPN that you can maintain in minutes. Add peers with one command, scan a QR code on your phone, and enjoy a private, encrypted tunnel wherever you are. Keep your system updated, back up the config folder, and you will have a reliable VPN for the long run.

Run Local AI Chat: Install Ollama and Open WebUI with Docker (GPU/CPU) on Ubuntu 22.04/24.04

Overview

This tutorial shows how to run large language models locally using Ollama and Open WebUI with Docker on Ubuntu 22.04 or 24.04. You will get a private, fast AI chat interface in your browser with optional NVIDIA GPU acceleration. We will cover prerequisites, Docker setup, GPU configuration, a ready-to-use docker-compose.yml, updates, backups, and troubleshooting. The steps also work for CPU-only machines.

What You Will Need

Before you begin, make sure you have the following:

  • Ubuntu 22.04 or 24.04 (freshly updated)
  • Docker Engine and Docker Compose plugin
  • Optional: NVIDIA GPU with recent drivers (e.g., 535+), CUDA-capable
  • At least 16 GB RAM recommended; more VRAM helps with larger models
  • 1 open TCP port for the web UI (default 3000)

Step 1: Install Docker and Compose

Install Docker from the official repository and enable it on boot. If you already have Docker, ensure it is up to date.

sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
newgrp docker

Step 2 (Optional): Enable NVIDIA GPU for Containers

If you have an NVIDIA GPU, install the NVIDIA Container Toolkit so Docker can access the GPU. First verify the GPU is detected:

nvidia-smi

Then install the container toolkit:

curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit.gpg
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
  sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit.gpg] https://#' | \
  sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt update
sudo apt install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

If you are on a CPU-only system, skip this step. The stack will still work, just slower.

Step 3: Create the Docker Compose File

Create a working directory and a docker-compose.yml. This configuration runs two services: Ollama (model runtime) and Open WebUI (browser UI). It includes a GPU-enabled section that you can remove if you are running on CPU.

mkdir -p ~/ai-stack && cd ~/ai-stack
nano docker-compose.yml
services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    restart: unless-stopped
    ports:
      - "11434:11434"
    volumes:
      - ollama-data:/root/.ollama
    environment:
      - OLLAMA_KEEP_ALIVE=12h
      - OLLAMA_NUM_PARALLEL=1
    deploy:
      resources:
        reservations:
          devices:
            - capabilities: ["gpu"]   # Remove this block on CPU-only hosts

  open-webui:
    image: ghcr.io/open-webui/open-webui:latest
    container_name: open-webui
    restart: unless-stopped
    depends_on:
      - ollama
    ports:
      - "3000:8080"
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
    volumes:
      - openwebui-data:/app/backend/data

volumes:
  ollama-data:
  openwebui-data:

For CPU-only systems, delete the deploy.resources.reservations.devices block under the ollama service to avoid GPU scheduling errors.

Step 4: Start the Stack and Pull a Model

Launch the containers in the background:

docker compose up -d

Pull a model with Ollama. The following example downloads a compact, general-purpose model:

docker exec -it ollama ollama pull llama3.1:8b

You can list available models or search the model library at the official Ollama registry. Popular options include llama3.1:8b, mistral:7b, and neural-chat. Larger models require more RAM/VRAM.

Step 5: Access the Web Interface

Open your browser and visit http://SERVER_IP:3000 to access Open WebUI. On first launch, create an admin user. In Settings, confirm the Ollama base URL is http://ollama:11434. Choose your default model and start chatting locally.

Useful Tips

Switch or Add Models: Use the Models section in Open WebUI or run docker exec -it ollama ollama pull MODEL:TAG. You can host multiple models and select them per chat.

Performance Tuning: On GPU hosts, keep drivers current. In low-VRAM scenarios, choose quantized models (e.g., Q4_K_M variants). Adjust OLLAMA_NUM_PARALLEL and context window settings to balance speed and quality.

Storage Paths: Models are stored in the ollama-data volume; Open WebUI data lives in openwebui-data. Back up both volumes regularly.

Updating and Maintenance

To update to the latest images without losing data, pull and recreate:

docker compose pull
docker compose up -d

To back up volumes, stop the stack and export them or bind-mount to a backup path. Example quick export:

docker run --rm -v ollama-data:/data -v $(pwd):/backup alpine tar czf /backup/ollama-data.tar.gz -C /data .
docker run --rm -v openwebui-data:/data -v $(pwd):/backup alpine tar czf /backup/openwebui-data.tar.gz -C /data .

Security Considerations

Do not expose port 3000 or 11434 directly to the internet. If remote access is required, use a reverse proxy (Caddy, Nginx, or Traefik) with HTTPS and authentication, or place the service behind a VPN like WireGuard or Tailscale. Limit container memory/CPU if sharing the host.

Troubleshooting

Permission denied on Docker: Run newgrp docker or log out/in after adding your user to the docker group.

GPU not detected in container: Ensure nvidia-smi works on the host, the NVIDIA Container Toolkit is installed, and you did not remove the GPU reservation block in compose. Restart Docker after changes.

Port already in use: Change ports in docker-compose.yml (e.g., 3001:8080) and recreate the stack.

Models fail to load due to memory: Choose smaller or quantized models, reduce context length, or add swap on the host.

Uninstall or Remove

To stop and remove the stack while keeping volumes:

docker compose down

To remove everything including data volumes:

docker compose down -v

Conclusion

With Docker, Ollama, and Open WebUI, you can run private AI models on your own hardware in minutes. This setup scales from a simple laptop to a GPU workstation and is easy to update and back up. Start with a lightweight model, then experiment with larger options as your resources allow.

Securely Expose Your Home Lab with Cloudflare Tunnel (Zero Trust) on Ubuntu and Docker

Overview

This step-by-step guide shows you how to publish a private web service to the internet securely using Cloudflare Tunnel (cloudflared) and Cloudflare Zero Trust—without opening inbound ports on your router. You will run the tunnel in Docker on Ubuntu, route your domain through Cloudflare, protect the app with Single Sign-On (SSO), and optionally verify JSON Web Tokens (JWT) at the application layer. This approach adds strong security, free TLS, DDoS protection, and granular access control to your self-hosted apps.

Prerequisites

You need an Ubuntu 22.04 or 24.04 server (bare metal or VM), Docker and Docker Compose installed, a domain you control, and a Cloudflare account. If your domain is not already on Cloudflare, change your registrar’s nameservers to Cloudflare’s to manage DNS and Zero Trust features.

Step 1 — Prepare your domain on Cloudflare

1. Log in to Cloudflare and add your domain if you have not already. Follow the prompts to update nameservers at your registrar. Propagation may take a few minutes.

2. In the Cloudflare dashboard, verify that DNS is active and the orange cloud is enabled for your root or subdomains.

Step 2 — Enable Zero Trust

1. Open the Zero Trust dashboard (also labeled “Zero Trust” or “Access” in Cloudflare). Create your Zero Trust account if prompted.

2. Under Settings > Authentication, connect an identity provider (e.g., Google, Microsoft Entra ID, GitHub) or enable the One-Time Pin option for quick protection.

Step 3 — Install Docker and Compose (if needed)

If Docker is not installed, use the official convenience script. Then enable and test it. Example:

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker
docker --version

Install Docker Compose v2 if it is not present (many modern Docker packages include it as docker compose):

docker compose version

Step 4 — Create a Tunnel in the Cloudflare Dashboard

1. In Zero Trust > Networks > Tunnels, click “Create a tunnel,” choose “Cloudflared,” name it (e.g., homelab-tunnel), and create it.

2. Copy the provided token. You will use this token in Docker to authenticate the tunnel without interactive login.

Step 5 — Run cloudflared in Docker

Create a working directory, then a docker-compose.yml file. Replace the placeholder token with yours.

mkdir -p ~/cloudflared && cd ~/cloudflared
nano docker-compose.yml

version: "3.8"
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel run
    restart: unless-stopped
    environment:
      - TUNNEL_TOKEN=<PASTE_YOUR_TUNNEL_TOKEN>
    volumes:
      - ./config:/etc/cloudflared

Bring it up:

docker compose up -d

The container will authenticate with Cloudflare automatically using the token. You should see the tunnel listed as “Healthy” in the Zero Trust dashboard within a minute.

Step 6 — Configure ingress rules to publish your service

Create a config file to route a public hostname to your internal service. For example, map app.yourdomain.com to a local web app on port 8080 and ha.yourdomain.com to Home Assistant on port 8123.

mkdir -p config
nano config/config.yml

tunnel: <YOUR_TUNNEL_ID>
credentials-file: /etc/cloudflared/<YOUR_TUNNEL_ID>.json
ingress:
  - hostname: app.yourdomain.com
     service: http://host.docker.internal:8080
  - hostname: ha.yourdomain.com
     service: http://host.docker.internal:8123
  - service: http_status:404

On Linux, host.docker.internal may not resolve by default. Use the host IP (e.g., http://192.168.1.50:8080) or attach the tunnel to the same Docker network as your app containers and reference them by service name (e.g., http://web:8080).

Restart the tunnel to apply changes:

docker compose restart

Step 7 — Route DNS to the tunnel

Back in Zero Trust > Networks > Tunnels, open your tunnel and add Public Hostnames. Enter app.yourdomain.com and select the corresponding service. Cloudflare will automatically create proxied DNS records and issue valid TLS certificates.

Step 8 — Protect the app with Cloudflare Access (SSO/MFA)

1. Go to Zero Trust > Access > Applications > Add an application > Self‑hosted.

2. Set the application domain to app.yourdomain.com, pick a name, and click Next.

3. Create a policy: allow emails from your domain (e.g., [email protected]) or a group from your IdP. Optionally require MFA with Cloudflare’s TOTP or your IdP’s enforced MFA.

4. Save. Now your app is behind an identity-aware proxy. Only authenticated users you choose can reach it.

Optional — Verify JWT in your application or reverse proxy

Cloudflare Access sends a signed JWT in the CF-Access-Jwt-Assertion header. Your app or Nginx can verify it for an extra layer of defense-in-depth. In Nginx, pass the header to upstream or validate with an auth_request service. If you use languages like Node.js, Python, or Go, verify using the public JWKs at https://<your-team>.cloudflareaccess.com/cdn-cgi/access/certs and check audience and issuer claims.

Monitoring and troubleshooting

Check health: In the Cloudflare dashboard, your tunnel should be Healthy. On the host, view logs with docker logs -f <container_name>.

403 after login: Ensure your Access policy allows your email or group, and that the application domain matches the hostname you visit.

Bad gateway: Verify the internal service address and port in config.yml. Confirm the service is reachable from the tunnel container’s network.

DNS mismatch: Confirm the Public Hostname in the tunnel matches the DNS entry and the Access application domain.

IPv6 or CGNAT: Cloudflare Tunnel works without inbound ports, even behind CGNAT. No router changes are needed.

Security best practices

Use strong identity controls with MFA and short Access session durations. Limit policies to specific users or groups, not anyone with the link. Avoid exposing administrative apps publicly unless protected by Access. Keep cloudflared updated by periodically pulling the latest Docker image, and restrict who can manage your Cloudflare account with role-based access.

Conclusion

With Cloudflare Tunnel and Zero Trust, you can publish internal apps safely without port forwarding or complicated firewall rules. You get automatic TLS, DDoS protection, identity-based access, and optional JWT validation. This modern pattern is ideal for homelabs, small businesses, and remote teams that need secure, simple access to self‑hosted services.

How to Securely Publish Self‑Hosted Apps with Cloudflare Tunnel and Zero Trust (Docker How‑To)

Overview

Cloudflare Tunnel lets you expose private services to the internet without opening inbound firewall ports or managing a reverse proxy. It establishes an outbound-only, encrypted connection from your host to Cloudflare’s edge, and pairs perfectly with Cloudflare Zero Trust Access for SSO, device checks, and detailed auditing. In this tutorial, you will deploy Cloudflare Tunnel with Docker, route multiple apps under different subdomains, and protect them with Zero Trust policies. The steps are simple, reproducible, and friendly to environments behind NAT or CGNAT.

Prerequisites

- A Cloudflare account with a domain managed by Cloudflare DNS.

- Docker and Docker Compose v2 on a Linux host (Ubuntu/Debian/Alpine are fine).

- At least one internal web service running in Docker or on the host (e.g., Grafana on port 3000, Nextcloud on 80, Syncthing on 8384).

- Optional but recommended: an identity provider (Google, GitHub, Azure AD, Okta) to enforce SSO via Zero Trust Access.

Step 1 — Create a Tunnel in Cloudflare Zero Trust

1) Go to Cloudflare dashboard > Zero Trust > Access > Tunnels and click “Create a tunnel.” Name it (e.g., home-net).

2) Choose the Docker option. Cloudflare will generate a TUNNEL_TOKEN for you. Keep this token safe; it lets cloudflared authenticate the tunnel without storing local credentials.

3) You can add “Public Hostnames” later in the dashboard, or define routing via a local config file. This guide shows both approaches, starting with the fast token-only method.

Step 2 — Fast Deploy with Docker Compose (Token Method)

Create a directory (e.g., /opt/cloudflared) and a Docker Compose file. Replace TUNNEL_TOKEN_VALUE with the token you copied in Step 1.

version: "3.8"
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=TUNNEL_TOKEN_VALUE
    # Optional metrics for monitoring
    # ports:
    #   - "2000:2000"
    # command: tunnel --no-autoupdate run --metrics 0.0.0.0:2000

Bring it up:

docker compose up -d

Cloudflared will dial out to Cloudflare’s edge over QUIC/TLS. No inbound ports are needed. Next, from the Zero Trust dashboard, add “Public Hostnames” for each app:

- grafana.example.com → http://localhost:3000 (or your internal service URL)

- files.example.com → http://localhost:80

- sync.example.com → http://localhost:8384

Cloudflare automatically creates DNS records for these hostnames and routes traffic through the tunnel.

Step 3 — Config-Driven Ingress (Advanced, Reproducible)

If you prefer everything as code, create a named tunnel and a local config file. This offers better portability and version control. First, create the tunnel credentials once on any machine (can be the same host):

# Authenticate and create a named tunnel (locally)
docker run --rm -it \
  -v ~/.cloudflared:/home/nonroot/.cloudflared \
  cloudflare/cloudflared:latest tunnel login

docker run --rm -it \
  -v ~/.cloudflared:/home/nonroot/.cloudflared \
  cloudflare/cloudflared:latest tunnel create home-net

# Show tunnels (note the Tunnel UUID)
docker run --rm -it \
  -v ~/.cloudflared:/home/nonroot/.cloudflared \
  cloudflare/cloudflared:latest tunnel list

This creates a credentials file named after the tunnel UUID. Now write config.yml in the same directory:

# ~/.cloudflared/config.yml
tunnel: HOME_NET_TUNNEL_UUID
credentials-file: /home/nonroot/.cloudflared/HOME_NET_TUNNEL_UUID.json
ingress:
  - hostname: grafana.example.com
    service: http://grafana:3000
  - hostname: files.example.com
    service: http://nextcloud:80
  - hostname: sync.example.com
    service: http://syncthing:8384
  - service: http_status:404
protocol: quic
warp-routing:
  enabled: false

If your apps run in Docker, place cloudflared on the same user-defined network so it can reach them by container name. Example Compose:

version: "3.8"
networks:
  apps:
    driver: bridge

services:
  grafana:
    image: grafana/grafana:latest
    networks: [apps]
    expose:
      - "3000"
    environment:
      - GF_SERVER_ROOT_URL=http://grafana.example.com

  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    networks: [apps]
    volumes:
      - ~/.cloudflared:/home/nonroot/.cloudflared:ro

Finally, register DNS routes (one-time):

docker run --rm -it \
  -v ~/.cloudflared:/home/nonroot/.cloudflared \
  cloudflare/cloudflared:latest tunnel route dns home-net grafana.example.com

docker run --rm -it \
  -v ~/.cloudflared:/home/nonroot/.cloudflared \
  cloudflare/cloudflared:latest tunnel route dns home-net files.example.com

docker run --rm -it \
  -v ~/.cloudflared:/home/nonroot/.cloudflared \
  cloudflare/cloudflared:latest tunnel route dns home-net sync.example.com

Step 4 — Protect with Zero Trust Access

Go to Zero Trust > Access > Applications > Add an application > Self-hosted. For each hostname you exposed, create an app and a policy:

- Choose your subdomain (e.g., grafana.example.com) and path (/*).

- Set a policy that requires SSO (Google/GitHub/Azure AD/Okta) or One-Time Pin.

- Optionally restrict to emails ending with @yourcompany.com or specific GitHub teams.

- Enable device posture checks if you use Cloudflare WARP (e.g., only allow managed devices).

With Access policies enforced, even if a URL leaks, visitors must authenticate before the origin sees any traffic.

Troubleshooting Tips

- 502/504 on a hostname: verify the upstream address in config points to a reachable service (container name + port or localhost + port). If using Docker networks, ensure both services share the same network.

- DNS not resolving: confirm the tunnel is “Healthy” and that the DNS record exists in your Cloudflare DNS zone. Propagation is usually instant because the record is proxied.

- Large uploads: consider enabling chunked uploads or tuning upstream app limits (e.g., client_max_body_size for Nginx-based apps). Cloudflare supports HTTP/2/3 on the edge; the tunnel runs over QUIC by default.

- Conflicting ports: cloudflared does not require inbound ports. If you exposed metrics on 2000, make sure it doesn’t collide with other services.

Security and Operations Best Practices

- Principle of least privilege: restrict Access policies to known users or groups; avoid wildcard “Allow all.”

- Separate hostnames for admin panels and public apps. Apply stricter policies (MFA, device checks) to admin endpoints.

- Use config as code where possible. Commit your cloudflared config.yml to a private repo and use environment-specific files for staging/production.

- Monitor tunnel health: expose metrics (–metrics 0.0.0.0:2000) and scrape with Prometheus. Alert on disconnects or high reconnect counts.

- Keep images updated: pin to a recent cloudflared tag and schedule updates. Test changes in staging first.

Wrap-Up

You deployed Cloudflare Tunnel with Docker, routed multiple internal services under friendly subdomains, and enforced Zero Trust Access in front of them—without opening a single inbound port. This pattern scales from homelabs to production, simplifies TLS and DNS, and raises your security baseline with SSO, device posture, logging, and revocation. If you outgrow manual steps, codify everything with config.yml, Compose files, and IaC for a repeatable, auditable setup.

Popular Posts

Install Ollama and Open WebUI on Ubuntu 24.04 with NVIDIA GPU Acceleration (Step-by-Step)

Install Ollama + Open WebUI on Ubuntu 24.04 with NVIDIA GPU Acceleration (Step-by-Step)

Install a Local AI Chatbot on Ubuntu 24.04 with Ollama and Open WebUI (Step-by-Step)

Trending Now

Recovering from Btrfs Boot Failures Using GUI Tools on Fedora

By the end of this guide the reader will be able to identify a Btrfs‑based Fedora installation, boot from a live USB, list and restore snapshots using the graphical utilities btrfs‑assistant and snapper, and verify that the system returns to a functional state without resorting to the command line. Understanding the Btrfs Layout Used by Fedora Fedora Workstation and Fedora KDE install the root filesystem as a single Btrfs partition that contains two default sub‑volumes. One sub‑volume holds the traditional “/” hierarchy, while the second is dedicated to /var/lib/machines . The latter exists to keep container images out of snapshot operations; it remains empty on systems that do not run virtual machines. Because Btrfs stores data in sub‑volumes rather than separate partitions, a snapshot captures the state of an entire sub‑volume at a point in time. The installer (Anaconda) automatically registers these sub‑volumes with the snapper service. Snapper maintains a series of read‑only ...