Tools: Home Lab Hero: Exposing Local Services Safely without Port Forwarding

Tools: Home Lab Hero: Exposing Local Services Safely without Port Forwarding

Building a Secure Private Cloud in 2026: Coolify, Directus & Cloudflare Tunnels

The Problem: The "Home Server" Headache

The Philosophy: A Three-Layer Fortress

The Stack

1. 🧩 Coolify — The Orchestrator

2. 🗄️ Directus — The Data Engine

3. 🌐 Cloudflare Tunnels — The Bridge

Step-by-Step: Building the Stack

Phase 1 — Deploying Directus with Coolify

Installing Coolify

Deploying Directus via Docker Compose

⚠️ The Critical PUBLIC_URL Setting

Phase 2 — Building the Cloudflare Tunnel

Prerequisites

Creating the Tunnel

Running cloudflared as a Container

Phase 3 — The Zero Trust Lockdown

What is Cloudflare Access?

Setting Up an Access Policy

How the OTP Login Works

Why This Stack Wins

✅ No VPN Required

✅ Free SSL/TLS, Automatically Managed

✅ Your Home IP is Completely Hidden

✅ Layered Security by Design

✅ Scales With You

Common Gotchas & Tips

Conclusion: Welcome to the Private Cloud How I stopped opening ports on my router and built a three-layer fortress for my home server — no VPN, no exposed IP, no compromise. We've all been there. You've spent a Sunday afternoon setting up a beautiful self-hosted service on your local machine — maybe a headless CMS like Directus, a media server like Jellyfin, or a custom internal tool you've been hacking on for weeks. It works perfectly on your home network. Then you step out to a café, pull out your phone, and the illusion shatters. You can't access it. You're locked out of your own creation. The traditional solutions are uncomfortable at best and dangerous at worst: There had to be a better way. In 2026, there is — and this post walks through exactly how I built it. Before diving into the technical steps, it's worth understanding the design philosophy behind this stack. Instead of reacting to security threats, we're building a system that is secure by default — where every layer reinforces the next. Think of it like this: This is the modern private cloud stack. Here's what each layer looks like in practice. Coolify is a self-hosted Platform-as-a-Service (PaaS) that brings the developer experience of platforms like Heroku or Railway to your own hardware. It wraps Docker and Docker Compose in a clean web UI, so you can deploy, manage, monitor, and update containerised applications with a few clicks — no memorising docker-compose flags required. Why Coolify instead of managing Docker directly? For anyone running more than one or two services on a home server, Coolify is transformative. It turns container orchestration from a chore into a pleasant experience. Directus is an open-source headless CMS and data platform. Unlike traditional CMS platforms that dictate your content structure, Directus wraps around any SQL database and instantly generates a REST and GraphQL API. It's database-first, meaning your data is never locked in. For this project, Directus serves as the content backend — the place where all data lives and is managed. It's the thing we ultimately want to access securely from anywhere in the world. This is the secret sauce. Cloudflare Tunnels (formerly Cloudflare Argo Tunnel) allows you to expose a locally running service to the internet without opening a single port on your router. Here's the magic: instead of the internet coming to you, your server reaches out to Cloudflare. The cloudflared daemon running on your machine establishes a persistent, encrypted, outbound-only connection to Cloudflare's global edge network. When a user visits your domain, Cloudflare routes the request through that tunnel to your local machine — without ever knowing (or exposing) your real IP address. Why Cloudflare Tunnels? The first phase is getting Directus up and running locally, managed by Coolify. Coolify is designed to run on a fresh Linux server or even a local machine. Installation is a single command: Once running, access the Coolify dashboard at http://your-server-ip:8000 and complete the initial setup. From here, everything is managed through the UI. In Coolify, create a new service and select Docker Compose as the deployment method. Use a compose file similar to the following: The single most important environment variable here is PUBLIC_URL. Directus uses this value to construct absolute URLs for things like password reset emails, file previews, and the Admin UI's API calls. If PUBLIC_URL is set to http://localhost:8055 but you access Directus via https://cms.yourdomain.com, the admin panel will make API calls to the wrong address and break in subtle, confusing ways. Set this to your final public domain from the start. In our case, that will be the domain we configure through Cloudflare. Once deployed, Coolify will show Directus as healthy and running — but it's only accessible on your local network for now. That changes in Phase 2. This is where things get elegant. We're going to create a secure bridge between Cloudflare's global network and our locally running Directus instance. Rather than installing the cloudflared binary directly on the host, we run it as a Docker container — keeping things clean and managed by Coolify. Add the following to your Docker Compose file (or deploy it as a separate Coolify service): Once this container starts, it establishes an outbound connection to Cloudflare. Within a few seconds, cms.yourdomain.com will resolve publicly — routing through Cloudflare's edge directly to your Directus container. No port forwarding. No firewall rules. No exposed IP address. Your Directus instance is now live on the internet. But before you celebrate, we need to lock it down. Having a publicly accessible URL is both exciting and frightening. Anyone with the URL can reach your Directus login screen. While Directus itself has authentication, relying solely on an application-level login is a single point of failure. We want an additional layer — one that stops uninvited guests before they even see Directus. Enter Cloudflare Access. Cloudflare Access is part of Cloudflare's Zero Trust product suite. It acts as an identity-aware proxy that sits in front of your application. Before a request reaches Directus, Cloudflare intercepts it and demands authentication. Only requests that pass the policy are forwarded through the tunnel. When a user navigates to cms.yourdomain.com, here's what happens: The result: even if someone discovers your cms.yourdomain.com URL, they cannot proceed without access to your email inbox. The Directus login screen is invisible to the outside world. Let's step back and appreciate what we've built. Traditional secure remote access requires a VPN client on every device. You have to remember to connect, manage certificates, and troubleshoot when it doesn't work. With Cloudflare Tunnels + Access, any browser on any device can securely access your service. Your phone, a borrowed laptop, a friend's computer — all work instantly with just an email and an OTP. Cloudflare handles HTTPS termination at the edge. You get a valid, trusted SSL certificate for your subdomain at no cost, with automatic renewal. There's no Certbot, no Let's Encrypt configuration, no annual renewal panic. Because cloudflared only makes outbound connections, there is no DNS record pointing to your home IP, no open port for scanners to find, and no way for a malicious actor to correlate your domain with your physical location or ISP. From the public internet's perspective, your service lives somewhere inside Cloudflare's network. Each layer must be bypassed independently. This is defence-in-depth done properly. This exact pattern works whether you're running one service or twenty. Each new service gets its own subdomain, its own tunnel route, and its own Access policy. Coolify handles the container orchestration; Cloudflare handles the networking and access control. The complexity doesn't grow with the number of services. Directus Admin UI breaking after external access?

Almost always a PUBLIC_URL misconfiguration. Double-check it matches your Cloudflare hostname exactly, including https://. Cloudflared container keeps restarting?Verify your TUNNEL_TOKEN is correct and that the tunnel is in an active state in the Cloudflare dashboard. OTP emails not arriving?Check your spam folder first. If using a custom domain email, ensure your SPF/DKIM records are correctly configured with Cloudflare. Want to allow a team member access?

Simply add their email to your Access Policy's include rule. They'll be able to authenticate with their own OTP without needing any VPN credentials or shared passwords. The era of opening Port 80 on your home router and crossing your fingers is over. Modern tools — Coolify for orchestration, Directus for your data layer, and Cloudflare Tunnels with Zero Trust Access for networking — give individual developers and small teams the kind of infrastructure that used to require a dedicated DevOps engineer and a cloud budget. The stack we've built today is: If you're still running services with a raw docker run command and a prayer, this is your sign to upgrade. The private cloud is here, and it's better than ever. Built and tested on a home server running Ubuntu 24.04 LTS. Stack versions: Coolify v4, Directus 11, cloudflared 2025.x. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ -weight: 500;">curl -fsSL https://cdn.coollabs.io/coolify/-weight: 500;">install.sh | bash -weight: 500;">curl -fsSL https://cdn.coollabs.io/coolify/-weight: 500;">install.sh | bash -weight: 500;">curl -fsSL https://cdn.coollabs.io/coolify/-weight: 500;">install.sh | bash version: "3" services: database: image: postgres:15-alpine environment: POSTGRES_USER: directus POSTGRES_PASSWORD: supersecretpassword POSTGRES_DB: directus volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U directus"] interval: 10s timeout: 5s retries: 5 directus: image: directus/directus:latest depends_on: database: condition: service_healthy ports: - "8055:8055" environment: KEY: "your-random-key-here" SECRET: "your-random-secret-here" DB_CLIENT: "pg" DB_HOST: "database" DB_PORT: "5432" DB_DATABASE: "directus" DB_USER: "directus" DB_PASSWORD: "supersecretpassword" ADMIN_EMAIL: "[email protected]" ADMIN_PASSWORD: "your-admin-password" PUBLIC_URL: "https://cms.yourdomain.com" # ← Critical! volumes: - directus_uploads:/directus/uploads volumes: postgres_data: directus_uploads: version: "3" services: database: image: postgres:15-alpine environment: POSTGRES_USER: directus POSTGRES_PASSWORD: supersecretpassword POSTGRES_DB: directus volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U directus"] interval: 10s timeout: 5s retries: 5 directus: image: directus/directus:latest depends_on: database: condition: service_healthy ports: - "8055:8055" environment: KEY: "your-random-key-here" SECRET: "your-random-secret-here" DB_CLIENT: "pg" DB_HOST: "database" DB_PORT: "5432" DB_DATABASE: "directus" DB_USER: "directus" DB_PASSWORD: "supersecretpassword" ADMIN_EMAIL: "[email protected]" ADMIN_PASSWORD: "your-admin-password" PUBLIC_URL: "https://cms.yourdomain.com" # ← Critical! volumes: - directus_uploads:/directus/uploads volumes: postgres_data: directus_uploads: version: "3" services: database: image: postgres:15-alpine environment: POSTGRES_USER: directus POSTGRES_PASSWORD: supersecretpassword POSTGRES_DB: directus volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U directus"] interval: 10s timeout: 5s retries: 5 directus: image: directus/directus:latest depends_on: database: condition: service_healthy ports: - "8055:8055" environment: KEY: "your-random-key-here" SECRET: "your-random-secret-here" DB_CLIENT: "pg" DB_HOST: "database" DB_PORT: "5432" DB_DATABASE: "directus" DB_USER: "directus" DB_PASSWORD: "supersecretpassword" ADMIN_EMAIL: "[email protected]" ADMIN_PASSWORD: "your-admin-password" PUBLIC_URL: "https://cms.yourdomain.com" # ← Critical! volumes: - directus_uploads:/directus/uploads volumes: postgres_data: directus_uploads: cloudflared: image: cloudflare/cloudflared:latest command: tunnel --no-autoupdate run environment: TUNNEL_TOKEN: "your-tunnel-token-from-cloudflare" -weight: 500;">restart: unless-stopped depends_on: - directus cloudflared: image: cloudflare/cloudflared:latest command: tunnel --no-autoupdate run environment: TUNNEL_TOKEN: "your-tunnel-token-from-cloudflare" -weight: 500;">restart: unless-stopped depends_on: - directus cloudflared: image: cloudflare/cloudflared:latest command: tunnel --no-autoupdate run environment: TUNNEL_TOKEN: "your-tunnel-token-from-cloudflare" -weight: 500;">restart: unless-stopped depends_on: - directus Public Internet │ ▼ Cloudflare Edge (DDoS protection, WAF) │ ▼ Cloudflare Access (Identity verification, OTP) │ ▼ Cloudflare Tunnel (Encrypted, outbound-only) │ ▼ Directus Application (App-level authentication) │ ▼ PostgreSQL (Internal network only, never exposed) Public Internet │ ▼ Cloudflare Edge (DDoS protection, WAF) │ ▼ Cloudflare Access (Identity verification, OTP) │ ▼ Cloudflare Tunnel (Encrypted, outbound-only) │ ▼ Directus Application (App-level authentication) │ ▼ PostgreSQL (Internal network only, never exposed) Public Internet │ ▼ Cloudflare Edge (DDoS protection, WAF) │ ▼ Cloudflare Access (Identity verification, OTP) │ ▼ Cloudflare Tunnel (Encrypted, outbound-only) │ ▼ Directus Application (App-level authentication) │ ▼ PostgreSQL (Internal network only, never exposed) - Port forwarding means punching holes in your router's firewall, directly exposing your home IP address to the public internet. One misconfigured -weight: 500;">service and you have a serious problem. - CGNAT (Carrier-Grade NAT) is increasingly common with ISPs and makes traditional port forwarding outright impossible for many users. Your router isn't even the outermost layer anymore — your ISP is. - Dynamic DNS solves the changing-IP problem but does nothing for the security issues above. - A VPN like WireGuard is a solid approach, but it requires you to remember to activate it on every device, every time. It's friction, and friction leads to bad habits. - The Orchestrator manages and deploys your services cleanly, in isolated containers. - The Application is your actual -weight: 500;">service, running privately with no public exposure. - The Bridge creates an outbound-only, encrypted tunnel to the internet — so traffic reaches you without anyone ever knowing where "you" are. - Visual dashboard for all running services - Built-in environment variable management - One-click deployments and redeployments - Automatic container health monitoring - Log streaming directly in the browser - Flexible, schema-agnostic data modelling - Auto-generated REST & GraphQL APIs - A beautiful, intuitive admin UI - Role-based access control out of the box - Works seamlessly with PostgreSQL, MySQL, SQLite, and more - Zero inbound ports required — works even behind CGNAT - Your home IP is completely hidden from the public - Free SSL/TLS certificates, automatically managed - Integrates natively with Cloudflare's Zero Trust access controls - Runs as a lightweight, containerised daemon - A domain name added to Cloudflare (with Cloudflare managing DNS) - A free Cloudflare account with Zero Trust enabled - Navigate to Cloudflare Dashboard → Zero Trust → Networks → Tunnels - Click Create a tunnel and give it a name (e.g., home-server) - Cloudflare will generate a unique Tunnel Token — copy this, you'll need it shortly - Under Public Hostnames, add a new route: Subdomain: cms Domain: yourdomain.com Service: http://directus:8055 (the internal Docker -weight: 500;">service name and port) - Subdomain: cms - Domain: yourdomain.com - Service: http://directus:8055 (the internal Docker -weight: 500;">service name and port) - Subdomain: cms - Domain: yourdomain.com - Service: http://directus:8055 (the internal Docker -weight: 500;">service name and port) - Go to Zero Trust → Access → Applications - Click Add an application → Self-hosted - Configure it: Application name: Directus CMS Application domain: cms.yourdomain.com - Application name: Directus CMS - Application domain: cms.yourdomain.com - Create an Access Policy: Policy name: Owner Access Action: Allow Include rule: Emails → [email protected] - Policy name: Owner Access - Action: Allow - Include rule: Emails → [email protected] - Application name: Directus CMS - Application domain: cms.yourdomain.com - Policy name: Owner Access - Action: Allow - Include rule: Emails → [email protected] - Cloudflare intercepts the request before it reaches Directus - The user is presented with a Cloudflare Access login screen - They enter their email address - If the email matches an allowed email in your policy, Cloudflare sends a One-Time PIN (OTP) to that address - The user enters the PIN, receives a signed JWT session cookie, and is forwarded to Directus - All subsequent requests carry this cookie, so Directus loads normally - Accessible — from any device, anywhere in the world, with just a browser - Secure — hidden IP, layered authentication, encrypted transit - Free (or nearly so) — Coolify is open-source, Directus has a generous self-hosted licence, and Cloudflare's free tier covers everything we've used here - Maintainable — clean abstractions at every layer, easy to -weight: 500;">update and extend