Expose local services to the internet via Cloudflare Tunnels. Use this skill when you need to make a locally running application accessible at a public HTTPS URL.
Flow: Start a local service (e.g. on port 3000) → cftunnel creates a tunnel + DNS route → the service is live at https://hostname.domain.com.
Set these environment variables before running any command:
bash
# Option A: API Key + Email (most common)
export CLOUDFLARE_API_KEY=<api-key>
export CLOUDFLARE_EMAIL=<account-email>
# Option B: API Token (scoped, if available)
export CLOUDFLARE_API_TOKEN=<api-token>
# Always required:
export CLOUDFLARE_ACCOUNT_ID=<account-id>
If a tunnel and cloudflared are already running (check with tunnel list), just add a route and DNS:
bash
# 1. Add ingress route to existing tunnel
npx cftunnel route add <tunnel-id> --hostname <hostname> --service http://localhost:<port>
# 2. Create DNS CNAME
npx cftunnel dns create --zone-id <zone-id> --hostname <hostname> --tunnel-id <tunnel-id>
bash
npx cftunnel quickstart \
--name <tunnel-name> \
--hostname <hostname> \
--service http://localhost:<port> \
--zone-id <zone-id>
Then run the connector: npx cftunnel run <tunnel-id>
typescript
import { createClient, quickstart } from 'cftunnel';
const client = createClient({ apiKey: '...', apiEmail: '...' });
const result = await quickstart(client, {
accountId: '...',
name: 'my-app',
hostname: 'app.example.com',
service: 'http://localhost:3000',
zoneId: '...',
});
console.log(result.run_cmd);
CommandPurposenpx cftunnel tunnel listList all tunnels. Find existing tunnel IDs and check status (healthy/down/inactive).npx cftunnel tunnel create <name>Create a new tunnel. Returns tunnel ID and secret. Add --config-src local for YAML-managed config.npx cftunnel tunnel get <tunnel-id>Get tunnel details including connection status.npx cftunnel tunnel delete <tunnel-id>Delete a tunnel. Must have no active connections.npx cftunnel tunnel token <tunnel-id>Get the token needed to run cloudflared. Returns run_cmd and install_cmd.
CommandPurposenpx cftunnel route list <tunnel-id>Show current ingress rules. Always has a catch-all 404 as last rule.npx cftunnel route add <tunnel-id> --hostname <host> --service <url>Add a route. Preserves existing routes, appends before catch-all. Optional --path for path filtering.npx cftunnel route remove <tunnel-id> --hostname <host>Remove a route by hostname.npx cftunnel route set <tunnel-id> --route host1=svc1 --route host2=svc2Replace ALL routes. Use for bulk configuration.
CommandPurposenpx cftunnel dns create --zone-id <zid> --hostname <host> --tunnel-id <tid>Create proxied CNAME pointing hostname to tunnel. Required for the hostname to resolve.npx cftunnel dns list --zone-id <zid>List all DNS records in the zone.npx cftunnel dns delete <record-id> --zone-id <zid>Delete a DNS record.
CommandPurposenpx cftunnel run <tunnel-id>Run cloudflared in foreground. Auto-detects cloudflared from PATH or npm package.npx cftunnel run <tunnel-id> --install-serviceInstall cloudflared as a persistent system service (survives reboots).
"I need to expose port N on a domain"
→ Check tunnel list for a healthy tunnel. If one exists, use route add + dns create. If not, use quickstart.
"I need to add another service to an existing tunnel"
→ route add + dns create. One tunnel can serve many hostnames.
"I need to change where a hostname points"
→ route remove --hostname X then route add --hostname X --service <new-url>. DNS stays the same.
"I need to take a service offline"
→ route remove --hostname X and optionally dns delete <record-id>.
"The tunnel exists but cloudflared isn't running"
→ npx cftunnel run <tunnel-id> or use tunnel token to get the token for manual cloudflared invocation.
The --service flag accepts these protocols:
FormatExampleUse casehttp://host:port``http://localhost:3000HTTP web apps, APIshttps://host:port``https://localhost:8443HTTPS backendstcp://host:port``tcp://localhost:5432Databases, raw TCPssh://host:port``ssh://localhost:22SSH accessunix:///path``unix:///tmp/app.sockUnix socket appshttp_status:CODE``http_status:404Static status response (catch-all)
All commands output JSON to stdout. Progress/errors go to stderr.
Parse with jq:
bash
TUNNEL_ID=$(npx cftunnel tunnel create my-app | jq -r '.id')
TOKEN=$(npx cftunnel tunnel token $TUNNEL_ID | jq -r '.token')
bash
cd /path/to/app && npm start &
npx cftunnel quickstart \
--name my-web-app \
--hostname app.example.com \
--service http://localhost:8080 \
--zone-id <zone-id>
npx cftunnel run <tunnel-id-from-output>
bash
npx cftunnel tunnel list | jq '.[] | select(.status == "healthy")'
npx cftunnel route add <tunnel-id> --hostname api.example.com --service http://localhost:4000
npx cftunnel dns create --zone-id <zone-id> --hostname api.example.com --tunnel-id <tunnel-id>
bash
npx cftunnel route remove <tunnel-id> --hostname app.example.com
npx cftunnel route add <tunnel-id> --hostname app.example.com --service http://localhost:9000
bash
npx cftunnel route remove <tunnel-id> --hostname app.example.com
npx cftunnel dns list --zone-id <zone-id>
npx cftunnel dns delete <record-id> --zone-id <zone-id>
npx cftunnel tunnel delete <tunnel-id>
typescript
import { createClient, createTunnel, addRoute, createDNS } from 'cftunnel';
const client = createClient(); // reads from env vars
const tunnel = await createTunnel(client, { accountId: '...', name: 'my-app' });
await addRoute(client, {
accountId: '...',
tunnelId: tunnel.id,
hostname: 'app.example.com',
service: 'http://localhost:3000',
});
await createDNS(client, {
zoneId: '...',
hostname: 'app.example.com',
tunnelId: tunnel.id,
});
cloudflared running to serve traffic. Creating a tunnel and routes alone is not enough.http_status:404 rule is always appended automatically. Do not add it manually.route set replaces ALL routes. Use route add/ route remove for incremental changes.FlagEnv VarDescription--api-token``CLOUDFLARE_API_TOKENCloudflare API token (bearer auth)--api-key``CLOUDFLARE_API_KEYCloudflare API key (requires --api-email)--api-email``CLOUDFLARE_EMAILCloudflare account email--account-id``CLOUDFLARE_ACCOUNT_IDCloudflare account ID