Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save RickCogley/2d5d61c5e54c41c3c6c33cbbe40cedbd to your computer and use it in GitHub Desktop.

Select an option

Save RickCogley/2d5d61c5e54c41c3c6c33cbbe40cedbd to your computer and use it in GitHub Desktop.
Hosting Lume CMS behind Cloudflare Access + Tunnel (a Cloudflare-native alternative)

Hosting Lume CMS behind Cloudflare Access + Tunnel (a Cloudflare-native alternative)

A community write-up of an alternative way to expose Lume CMS on a VPS, for teams already using Cloudflare.

The official guide (Lume CMS → Deployment → VPS, lumeland/cms-deploy) sets up Caddy as a public HTTPS reverse proxy (ports 80/443), a DNS A record → your server IP, the CMS as a systemd service, and username/password basic auth in an .env file. That works great and is the right default.

This variant keeps the Deno + systemd + git base, but replaces the public-facing layer: instead of Caddy on public ports with a shared password, the CMS sits behind a Cloudflare Tunnel (no public web ports at all) and is authenticated by Cloudflare Access (per-user SSO / one-time PIN). As a bonus, it's noticeably faster for geographically distant editors, because requests terminate at the nearest Cloudflare edge instead of hopping to your VPS region.

Not a replacement for the official guide — just an option if you're on Cloudflare and want SSO + a closed origin. Generic throughout; substitute your own hostname/port.

Why you might want this

Official cms-deploy This Cloudflare variant
Caddy serves public :80/:443; origin reachable by IP No public web ports; origin reachable only via the tunnel
Shared username/password in .env Per-user sign-in (SSO / OTP), scoped to your company domain; no app credential
TLS via Caddy/Let's Encrypt on the box TLS terminated at the Cloudflare edge
Direct hop to your VPS region Terminates at the nearest Cloudflare edge → faster for distant users
Origin IP is exposed (and scannable) Origin IP never published; direct hits are firewalled off

Trade-offs: there will be a dependency on Cloudflare (a zone on Cloudflare + Zero Trust enabled — the free tier covers up to 50 users), and there's a little more dashboard setup. If you don't use Cloudflare, the official Caddy setup is simpler.

When we enabled this, we found the CMS is much snappier compared to going direct to VPS. We are hitting an edge-proxied Cloudflare DNS endpoint like cms.example.com, at a Cloudflare point of presence near us, and tunnelling in via the cloudflared daemon.

Architecture

%%{init: {'flowchart': {'nodeSpacing': 20, 'rankSpacing': 30, 'padding': 4}}}%%
flowchart LR
    U[Editor browser] --> E[Cloudflare edge<br/>cms.example.com]
    E --> A{Access policy<br/>company domain?}
    A -->|deny| L[Access login / blocked]
    A -->|allow + JWT| T[Cloudflare Tunnel]
    T --> C[cloudflared on VPS]
    C --> S[lume --serve<br/>127.0.0.1:3000]
Loading

The only inbound port the VPS keeps open to the internet is SSH. Cloudflare's cloudflared dials out to Cloudflare, so there's no inbound tunnel port to attack. Cloudflare "Access" authenticates at the edge, and (optionally) the tunnel re-validates the Access JWT before traffic reaches the CMS. ("Access" is part of Cloudflare Zero Trust, again free up to 50 users.)

What changes vs. the official setup

You can still use cms-deploy's install.sh to get Deno, the CMS systemd service, and the git workflow. Then, instead of the Caddy + public-DNS layer:

  1. Bind the CMS to loopback only.
  2. Create a Cloudflare Access application for the hostname.
  3. Install the cloudflared connector and route the hostname through a tunnel.
  4. Delete the public A/AAAA record (the tunnel creates a proxied CNAME).
  5. Close public ports 80/443 in the firewall; you can disable Caddy.

1. Bind the CMS to loopback

In _config.ts:

const site = lume({
  location: new URL("https://blog.example.com"),
  server: { hostname: "127.0.0.1", port: 3000 }, // IPv4 loopback, explicit
}, { markdown });

Gotcha that might bite: use 127.0.0.1, not localhost. On Ubuntu, localhost resolves to IPv6 [::1] first; if the CMS binds to IPv4 127.0.0.1 but the tunnel is pointed at localhost, cloudflared dials [::1]:3000 and gets connection-refused. Pin IPv4 on both ends.

Confirm:

ss -ltnp | grep :3000      # want 127.0.0.1:3000, NOT 0.0.0.0 / :::3000
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/admin   # 200

Auth note: on recent Lume the CMS runs via lume --serve, and the plugin only applies cms.auth() when the site location.hostname isn't localhost. Depending on how you run it, the built-in basic auth may not engage in serve mode — moving auth to the Cloudflare edge side-steps that question entirely. (You can still keep cms.auth() if you like; Access simply sits in front.)

2. Create the Access application

Cloudflare Zero Trust → Access → Applications → Add → Self-hosted:

  • Hostname: e.g. subdomain cms, domain example.com, path blank (whole host).
  • Policy: Allow, include rule Emails ending in @yourcompany.com (not "Everyone").
  • Login methods: one-time PIN (email) and/or Google / your IdP.

Access only evaluates traffic that flows through Cloudflare — i.e. a proxied hostname. The tunnel below provides that.

3. Install cloudflared + create the tunnel

Zero Trust → Networks → Tunnels → Create a tunnel → Cloudflared, name it, copy the install command (it contains a connector token — treat it as a secret). On the VPS:

sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-public-v2.gpg \
  | sudo tee /usr/share/keyrings/cloudflare-public-v2.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-public-v2.gpg] https://pkg.cloudflare.com/cloudflared any main' \
  | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared
sudo cloudflared service install <CONNECTOR_TOKEN>

Verify several connections registered:

journalctl -u cloudflared -n 20 --no-pager | grep -i "Registered tunnel connection"

4. Route the hostname (and turn on JWT validation)

In the tunnel's Public Hostname → Add:

  • Subdomain cms, domain example.com, path blank.
  • Service HTTP127.0.0.1:3000 (again, not localhost).

Saving auto-creates the proxied CNAME. Then in that route's advanced settings → Access, enable Enforce Access JWT validation and select your app, so cloudflared itself rejects any request without a valid Access token (good defense-in-depth when the app has no auth of its own).

5. Clean up DNS + lock down the origin

Delete the old cms.example.com A/AAAA records pointing at the VPS IP (the tunnel's CNAME replaces them). Then close public web ports and drop Caddy:

sudo ufw --force delete allow 80
sudo ufw --force delete allow 443
sudo ufw status                       # expect only 22/tcp
sudo systemctl disable --now caddy    # tunnel goes straight to :3000

Final listener check — only SSH + the loopback CMS:

ss -ltnp | grep -E ':22 |:443|:80 |:3000'

Verify (all three)

# Authenticated (signed-in / device-enrolled company user):
curl -s -o /dev/null -w '%{http_code}\n' https://cms.example.com/admin   # 200

# Origin bypass is dead (force-connect to the VPS IP):
curl --resolve cms.example.com:443:YOUR.VPS.IP -s -o /dev/null \
  -w '%{http_code}\n' --max-time 8 https://cms.example.com/admin         # 000

And by hand: open the hostname in an incognito window, signed out (pause any device VPN/agent) → you should hit the Cloudflare Access login and be denied without a company identity. Enrolled devices auto-authenticate, so "it works for me" isn't a test of the gate — always check the deny path from the outside.

Day-to-day

  • Deploy/update: unchanged — git pull && sudo systemctl restart <cms-service> on the VPS.
  • Manage who can edit: change the Cloudflare Access policy in the CF dashboard; no server change.
  • Cost: Cloudflare Zero Trust free tier (≤ 50 users).

Gotchas cheat-sheet

  • 127.0.0.1, not localhost, in the tunnel service URL (the IPv6 ::1 trap).
  • Access needs a proxied hostname — the tunnel provides it; a grey-cloud/DNS-only record bypasses Access.
  • Scope the Access policy to your domain, not "Everyone".
  • Turn on tunnel JWT validation if the CMS has no auth of its own.
  • Your own enrolled devices auto-authenticate — test the deny path from an incognito browser window.

Credits

Builds directly on the official Lume CMS VPS guide and lumeland/cms-deploy — this just swaps the public-facing layer for a Cloudflare-native one. Thanks to the Lume team for the CMS and the deployment tooling. Shared in case it's useful to other Lume users who also use Cloudflare.

#!/usr/bin/env sh
# install-cloudflare.sh
#
# Deploy LumeCMS on a VPS behind a Cloudflare Tunnel, authenticated by
# Cloudflare Access. A Cloudflare-native alternative to the Caddy-based
# cms-deploy (https://github.com/lumeland/cms-deploy).
#
# Differences vs the official install.sh:
# - No Caddy and no public 80/443. `cloudflared` dials OUT to Cloudflare, so
# the VPS exposes only SSH. The origin IP is never reachable directly.
# - Auth is handled by Cloudflare Access (per-user SSO / one-time PIN), not a
# shared .env username/password. cms.auth() in _cms.ts is optional; Access
# sits in front either way.
# - The CMS runs as a small systemd service on 127.0.0.1; the tunnel routes
# to it.
#
# Requirements: Ubuntu 24.04 with sudo, a domain whose zone is on Cloudflare,
# and Cloudflare Zero Trust enabled (free tier covers <=50 users).
#
# Note: the Access application and the tunnel's public-hostname route are
# configured in the Cloudflare dashboard (instructions printed at the end) —
# they aren't scriptable here without a scoped API token.
# Install and update packages
sudo apt update -y
sudo apt install -y curl git unzip
# Install Deno (local, like the official script)
curl -fsSL https://deno.land/install.sh > deno.sh
deno_dir="$(pwd)/.deno"
DENO_INSTALL="${deno_dir}" sh deno.sh -y --no-modify-path
rm deno.sh
deno="${deno_dir}/bin/deno"
# Install cloudflared (Cloudflare apt repo)
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-public-v2.gpg \
| sudo tee /usr/share/keyrings/cloudflare-public-v2.gpg > /dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-public-v2.gpg] https://pkg.cloudflare.com/cloudflared any main' \
| sudo tee /etc/apt/sources.list.d/cloudflared.list > /dev/null
sudo apt update -y
sudo apt install -y cloudflared
# Ask for required variables
read -p "The SSH URL of the repository: " repo
read -p "Your email: " email
read -p "The CMS hostname (cms.example.com): " domain
read -p "Local port for the CMS (3000): " port
port=${port:-3000}
dir="$(pwd)/${domain}"
# Create a SSH deploy key
ssh_file="$(pwd)/.ssh/id_rsa_${domain}"
mkdir -p "$(pwd)/.ssh"
ssh-keygen -t rsa -b 4096 -C "${email}" -N "" -f "${ssh_file}"
echo "Add the following deploy key to the GitHub repository settings"
echo "and allow write access:"
echo "---"
cat "${ssh_file}.pub"
echo "---"
read _
# Setup git repository
git -c core.sshCommand="ssh -i ${ssh_file}" clone "${repo}" "${dir}"
cd "${dir}"
git config user.email "${email}"
git config user.name LumeCMS
git config pull.rebase false
git config core.sshCommand "ssh -i ${ssh_file}"
cd ..
# Launcher: run `lume --serve` (auto-loads _cms.ts) bound to loopback only.
# IMPORTANT: 127.0.0.1, not localhost — on Ubuntu localhost resolves to IPv6
# ::1 first, and the tunnel would fail to reach an IPv4-only listener.
cat > "${dir}/.cms-serve.sh" << EOF
#!/usr/bin/env sh
cd "${dir}"
echo "import 'lume/cli.ts'" | "${deno}" run -A - -s --hostname=127.0.0.1 --port=${port}
EOF
chmod +x "${dir}/.cms-serve.sh"
# Systemd service for the CMS
service="lumecms-${domain}"
sudo tee /etc/systemd/system/${service}.service > /dev/null << EOF
[Unit]
Description=LumeCMS (${domain})
Documentation=https://lume.land/cms/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/env sh ${dir}/.cms-serve.sh
WorkingDirectory=${dir}
Restart=always
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now ${service}
# Install the cloudflared connector
echo ""
echo "Now create a tunnel in the Cloudflare Zero Trust dashboard:"
echo " Networks > Tunnels > Create a tunnel > Cloudflared > (name it)"
echo "then copy the connector token from the install command it shows."
read -p "Paste the cloudflared connector token: " cf_token
sudo cloudflared service install "${cf_token}"
# Firewall: SSH only. No 80/443 — the tunnel is outbound-only.
sudo ufw allow ssh
sudo ufw --force enable
sudo systemctl enable ufw
# Final dashboard steps (not scriptable without an API token)
cat << EOF
================================================================================
VPS side done. The CMS is serving on 127.0.0.1:${port}, and cloudflared is
connected. Finish in the Cloudflare dashboard:
1) Tunnel > Public Hostname > Add:
Subdomain/Domain = ${domain} Path = (blank)
Service = HTTP -> 127.0.0.1:${port} <-- 127.0.0.1, NOT localhost
This auto-creates a proxied CNAME. Delete any old A/AAAA records for
${domain} that point at this server's IP.
2) Zero Trust > Access > Applications > Add an application > Self-hosted:
Application hostname = ${domain} (path blank)
Policy = Allow, include "Emails ending in @yourcompany.com" (NOT "Everyone")
3) On the tunnel route > advanced settings > Access:
Enable "Enforce Access JSON Web Token (JWT) validation" and select the app
(defense in depth, since the CMS does no auth of its own).
Verify: open https://${domain} in an incognito window (signed out / VPN off) —
you should hit the Cloudflare Access login and be denied without a company
identity. Direct hits to this server's IP are firewalled off.
Update later with: cd ${dir} && git pull && sudo systemctl restart ${service}
================================================================================
EOF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment