Back to tutorials
Tutorial

Set Up Cloudflare Tunnel on a VPS for Private Admin

Set up Cloudflare Tunnel on a VPS to access wp-admin, phpMyAdmin, and dashboards privately—no open inbound ports.

By Anurag Singh
Updated on Nov 14, 2026
Category: Tutorial
Share article
Set Up Cloudflare Tunnel on a VPS for Private Admin

You don’t need to expose SSH, wp-admin, phpMyAdmin, or internal dashboards to the public internet. A Cloudflare Tunnel on a VPS gives you HTTPS access to private admin endpoints with identity checks.

You can keep inbound ports closed. Most setups still keep 80/443 open for the public site.

This guide takes you from zero to a working tunnel using cloudflared on Ubuntu/Debian/AlmaLinux/Rocky. You’ll route traffic to local services like Nginx vhosts, a phpMyAdmin container, Grafana, and Plesk/cPanel-style panels.

You’ll also add Cloudflare Access authentication. Each section includes quick checks so you can validate progress as you go.

What you’ll build (and what you won’t)

  • Private admin hostnames like admin.example.com, pma.example.com, grafana.example.com that only you (or your team) can reach.
  • No public exposure of those admin endpoints (no direct inbound to your VPS for them).
  • HTTPS end-to-end from your browser to Cloudflare, then to your VPS via the tunnel.
  • Access control using Cloudflare Access (email/SSO + optional device posture depending on your plan).

This doesn’t replace basic server hardening. You still need sensible SSH settings, security updates, and firewall rules.

If you want brute-force protection for public Nginx endpoints, pair this with Fail2ban for Nginx.

Prerequisites checklist

  • A VPS where you can install packages (root or sudo). If you’re running multiple sites/apps, a HostMyCode VPS gives you the control you need for tunnels, reverse proxies, and firewall policy.
  • A domain in Cloudflare DNS (orange-cloud proxy enabled is fine; tunnel uses its own routing).
  • At least one admin service to protect, such as:
    • WordPress admin behind Nginx/Apache
    • phpMyAdmin (container or package install)
    • A server dashboard like Grafana/Netdata (local port)
  • Outbound connectivity from the VPS to Cloudflare (typical hosting allows this).

Plan your private admin hostnames

Choose hostnames that won’t interfere with your public site routing. A typical setup looks like this:

  • admin.example.com → WordPress /wp-admin (or a full admin-only vhost)
  • pma.example.com → phpMyAdmin
  • ops.example.com → internal dashboards (Grafana, Uptime Kuma, etc.)

Put each tool on its own hostname. It keeps Access policies and audit logs easy to read.

It also avoids edge cases you can hit with path-based routing.

Install cloudflared (Ubuntu, Debian, AlmaLinux, Rocky)

On a fresh VPS, update packages first:

sudo apt update && sudo apt -y upgrade

Ubuntu/Debian (APT repo)

curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo gpg --dearmor -o /usr/share/keyrings/cloudflare-main.gpg

echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(. /etc/os-release && echo $VERSION_CODENAME) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list

sudo apt update
sudo apt -y install cloudflared

AlmaLinux/Rocky (RPM)

sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://pkg.cloudflare.com/cloudflared-ascii.repo
sudo dnf -y install cloudflared

Confirm the install:

cloudflared --version
which cloudflared

You’re looking for a version string and a path like /usr/bin/cloudflared.

Create the tunnel and authenticate Cloudflare Tunnel on a VPS

This step links your VPS to your Cloudflare account. On the VPS, run:

cloudflared tunnel login

cloudflared will print a URL. Open it in a browser where you can sign in to Cloudflare.

Choose the correct account and domain, then approve the request.

Back on the server, confirm the cert was written:

ls -la ~/.cloudflared

Now create a named tunnel:

cloudflared tunnel create private-admin

The command returns a Tunnel UUID. Save it.

It also writes credentials to ~/.cloudflared/<UUID>.json.

Point private hostnames to local services (ingress config)

Create the config directory and YAML:

sudo mkdir -p /etc/cloudflared
sudo nano /etc/cloudflared/config.yml

Use this template. Then swap in your UUID, hostnames, and local ports:

tunnel: <YOUR_TUNNEL_UUID>
credentials-file: /root/.cloudflared/<YOUR_TUNNEL_UUID>.json

# Optional but recommended: reduces noisy timeouts in logs
no-autoupdate: true

ingress:
  - hostname: admin.example.com
    service: http://127.0.0.1:8080
  - hostname: pma.example.com
    service: http://127.0.0.1:8081
  - hostname: ops.example.com
    service: http://127.0.0.1:3000
  - service: http_status:404

What those ports represent:

  • 127.0.0.1:8080 could be an Nginx server block that only listens locally and proxies to your WordPress admin.
  • 127.0.0.1:8081 could be phpMyAdmin (Docker container bound to localhost only).
  • 127.0.0.1:3000 could be Grafana listening on localhost.

If your admin service already runs on HTTPS locally, you can point to https://127.0.0.1:PORT.

For self-signed local certs, you may need originRequest: noTLSVerify: true per hostname.

In most cases, plain HTTP on localhost is the simplest and least fragile choice.

Create DNS routes for the tunnel

These commands create proxied DNS records in your Cloudflare zone and attach them to the tunnel.

cloudflared tunnel route dns private-admin admin.example.com
cloudflared tunnel route dns private-admin pma.example.com
cloudflared tunnel route dns private-admin ops.example.com

Verify what Cloudflare has on record:

cloudflared tunnel route dns show

You should see each hostname mapped to your tunnel.

Make admin endpoints local-only (Nginx examples)

The tunnel only helps if the origin services are actually private.

If 127.0.0.1:8080 is listening on 0.0.0.0, anyone can still hit it directly.

Option A: Nginx vhost that listens on localhost only

Create an internal server block dedicated to WordPress admin routing.

On Ubuntu/Debian Nginx, edit:

sudo nano /etc/nginx/sites-available/wp-admin-internal

Example (adjust paths and PHP socket):

server {
  listen 127.0.0.1:8080;
  server_name admin.example.com;

  root /var/www/example.com/public;
  index index.php;

  # Only allow wp-admin and wp-login via this hostname
  location / {
    return 404;
  }

  location ~ ^/(wp-admin/|wp-login\.php) {
    try_files $uri $uri/ /index.php?$args;
  }

  location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
  }
}

Enable it and reload:

sudo ln -s /etc/nginx/sites-available/wp-admin-internal /etc/nginx/sites-enabled/wp-admin-internal
sudo nginx -t
sudo systemctl reload nginx

Verify locally from the VPS:

curl -I http://127.0.0.1:8080/wp-login.php

You should get a 200 or 302.

Next, confirm it’s not reachable on the public interface by checking listening sockets:

sudo ss -lntp | grep 8080

It must show 127.0.0.1:8080, not 0.0.0.0:8080.

Option B: Bind containers to localhost only (phpMyAdmin example)

If you run phpMyAdmin in Docker, bind it to localhost. That keeps it reachable only through the tunnel.

Create a directory:

mkdir -p ~/pma && cd ~/pma
nano docker-compose.yml
services:
  phpmyadmin:
    image: phpmyadmin:5
    container_name: phpmyadmin
    restart: unless-stopped
    environment:
      - PMA_HOST=127.0.0.1
      - UPLOAD_LIMIT=128M
    ports:
      - "127.0.0.1:8081:80"

Start it:

docker compose up -d
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

Verify from the VPS:

curl -I http://127.0.0.1:8081/

The classic mistake here is binding 0.0.0.0:8081:80. That makes phpMyAdmin public again.

Run the tunnel as a system service

The packaged cloudflared can install a systemd service that uses your config file.

Run:

sudo cloudflared --config /etc/cloudflared/config.yml service install

Then enable and start it:

sudo systemctl enable cloudflared
sudo systemctl start cloudflared

Check status and recent logs:

sudo systemctl status cloudflared --no-pager
sudo journalctl -u cloudflared -n 100 --no-pager

Verification: Visit https://admin.example.com in your browser.

Before you add Access policies (next section), it should already proxy to your local service.

If you see a 404, double-check your ingress rules and the fallback line at the bottom.

Add Cloudflare Access authentication (the step that makes it “private”)

DNS routing doesn’t decide who’s allowed in. Cloudflare Access does.

You’ll create one app per hostname. Then you’ll require identity (email OTP, Google Workspace, GitHub, Okta, and so on).

  1. Open Cloudflare Dashboard → Zero Trust → Access → Applications.
  2. Choose Add an application → Self-hosted.
  3. Application domain: admin.example.com.
  4. Create an Allow policy for specific emails or your identity provider group.

Repeat for pma.example.com and ops.example.com.

Separate apps keep policies easy to reason about. They also let you tighten rules for high-risk tools like phpMyAdmin.

Quick hardening choices inside Access

  • Require MFA if your IdP supports it.
  • Short session duration for phpMyAdmin (e.g., 1–4 hours).
  • Block by country if your admin team is region-specific (use with care).

Verification: open an incognito window and load https://admin.example.com.

You should land on the Access login screen—not WordPress.

Lock down firewall rules without breaking your site

Tunnels remove the need to open inbound ports for admin tools. Your public website may still need 80/443 open.

Private services should not require anything inbound.

On Ubuntu with UFW, a minimal “public web + SSH” setup often looks like this:

sudo ufw default deny incoming
sudo ufw default allow outgoing

# SSH (consider limiting to your IPs)
sudo ufw allow 22/tcp

# Public website
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

sudo ufw enable
sudo ufw status verbose

If you previously exposed admin tools on ports like 3000, 8080, or 10000, remove those allow rules.

Do it only after you confirm tunnel access works end to end.

Common failure modes and fast diagnostics

1) Cloudflare shows “502 Bad Gateway”

  • Your local service is down. Test on the VPS: curl -I http://127.0.0.1:8080/
  • Your service listens on the wrong interface/port. Check: sudo ss -lntp
  • Nginx/PHP-FPM misrouting. If this is WordPress, also see Nginx 502 fixes.

2) You always get the fallback 404

  • Hostname typo in /etc/cloudflared/config.yml.
  • DNS route not created. Check: cloudflared tunnel route dns show.
  • Ingress rule order is wrong. The first matching hostname wins.

3) Access login loops or denies valid users

  • Policy doesn’t match the user email/domain.
  • Multiple Access apps overlap on the same hostname.
  • Your browser blocks third-party cookies in strict mode (rare now, but still happens). Try a different browser to confirm.

4) WordPress admin loads but styles/scripts 403/404

If you try to protect only /wp-admin on the main site without a dedicated admin hostname, it’s easy to break /wp-admin/admin-ajax.php or related assets.

The clean fix is what you set up above: a dedicated admin hostname for admin routes, while the public hostname stays untouched.

If you’re tuning WordPress performance anyway, Redis object cache is worth considering. It cuts down repeated database hits in both admin and front-end views.

HostMyCode has a clear walkthrough: set up Redis for WordPress.

Optional: add service-to-service protection for sensitive apps

Many tools (Grafana and internal admin UIs in particular) support their own auth layer—basic auth, local users, or SSO. Leave that enabled.

Access should be the front door, not the only lock.

If you publish metrics or logs dashboards, avoid anonymous access.

Tighten “viewer” accounts and disable signups where the app allows it.

Operational notes: updates, backups, and change control

  • Update cloudflared with your normal system updates. On Ubuntu/Debian: sudo apt update && sudo apt -y upgrade. On Alma/Rocky: sudo dnf -y upgrade.
  • Back up your config: /etc/cloudflared/config.yml and the credentials JSON under ~/.cloudflared. Treat credentials like secrets.
  • Test restores for the services you just protected. If phpMyAdmin is part of your recovery workflow, practice restoring the database the right way. For MySQL/MariaDB, follow automatic MySQL backups with tested restores.

Summary: a safer admin surface without extra public ports

A Cloudflare Tunnel works well for day-to-day admin access because it reduces what your VPS exposes.

Keep admin services on 127.0.0.1, let Cloudflare handle HTTPS and identity checks, and keep your firewall rules straightforward.

If you run multiple sites, containers, and dashboards on one server, leave CPU and RAM headroom.

A managed VPS hosting plan can also help if you’d rather hand off patching, service restarts, and baseline security maintenance.

If you want private admin access without complicated networking, start with a HostMyCode VPS and bind admin endpoints to localhost. If you don’t want to babysit systemd services, logs, and firewall rules, managed VPS hosting is the clean upgrade.

FAQ

Do I still need ports 80 and 443 open on the VPS?

For a public website, yes—your normal Nginx/Apache traffic still needs 80/443. For private admin tools served only via the tunnel, you don’t need to open additional inbound ports.

Can I use this to protect a control panel like Plesk or DirectAdmin?

Yes, as long as the panel can listen on localhost or a private interface and you route the tunnel to that internal address/port. Don’t expose the panel directly to the internet if you don’t need to.

Where is the tunnel config and how do I validate it?

This tutorial uses /etc/cloudflared/config.yml. Validate your origin services with curl to 127.0.0.1 and check sudo journalctl -u cloudflared for routing errors.

Is Cloudflare Access enough, or should I also add app-level auth?

Keep app-level auth enabled. Cloudflare Access is a strong gate, but you’ll want defense-in-depth for tools like phpMyAdmin and Grafana.

What’s the simplest way to avoid breaking WordPress assets?

Use a dedicated hostname (like admin.example.com) that serves only /wp-admin and /wp-login.php, instead of trying to apply tunnel/auth rules only to paths on the main site.