| name | cloudflare-tunnel-publish |
| version | 1.3.0 |
| description | Publish a local service to a user-owned custom domain via Cloudflare Tunnel.
Use when the user wants their own domain instead of community.iamstarchild.com (e.g. app.mydomain.com, fix SSL 521 on a custom domain).
|
| keywords | cloudflare, tunnel, cloudflared, custom domain, subdomain, hostname, DNS, SSL, TLS, publish, host, bind, expose, ่ชๅฎไนๅๅ, ่ชๅทฑ็ๅๅ, ็ปๅฎๅๅ, ๅญๅๅ, ไบ็บงๅๅ, ๅๅ่งฃๆ, ไธ็บฟ, ๅๅธ, ้จ็ฝฒ, ERR_SSL_VERSION_OR_CIPHER_MISMATCH, 502, 521, 530 |
| triggers | ["ๆณ็จๆ่ชๅทฑ็ๅๅ","ๆๆๅกๅๅธๅฐๆ็ๅๅ","ๆ็ฝ็ซ็ปๅฎๅฐๆ็ๅๅ","็ปๅฎๆไนฐ็ๅๅ","่ชๅฎไนๅๅ","่ชๅทฑ็ๅญๅๅ","ไธ่ฆ community.iamstarchild.com","hello.example.com ่ฟ็งๅฐๅ","cloudflare tunnel","publish to my own domain","bind a custom domain","host on my domain","subdomain pointing to localhost","expose localhost on a domain","point my domain at this service","ERR_SSL_VERSION_OR_CIPHER_MISMATCH","530 cloudflare","521 web server is down"] |
| metadata | {"starchild":{"emoji":"๐","skillKey":"cloudflare-tunnel-publish","requires":{"env":["CLOUDFLARE_API_TOKEN"],"bins":["python3","curl"]}}} |
| user-invocable | true |
| disable-model-invocation | false |
What this skill does
Turns a service running on a local port (default: a Starchild preview, but works for any HTTP service) into something the world can reach at app.userdomain.com, using Cloudflare Tunnel. No public IP required, no inbound ports opened, free SSL.
Two roles in the flow:
- User does manually (must, can't be automated): create Cloudflare account, buy/transfer domain to Cloudflare, create API Token.
- Agent does automatically (this skill): verify token, pick zone, create tunnel, configure ingress, create DNS, install + start
cloudflared, verify the public URL works.
Audience assumption
Treat the user as a beginner. They may have never used Cloudflare. Walk them through one micro-step at a time, wait for confirmation, then move on. Do NOT dump the whole 10-step plan and disappear.
Workflow
Phase 0 โ Set the stage (1 message)
Tell the user in plain language what's about to happen, in 4 phases:
- They register a Cloudflare account + add a domain (manual, ~5 min)
- They create an API Token and give it to you securely (manual, ~2 min)
- You build the tunnel + DNS + start it (automatic, ~1 min)
- You test the URL together (automatic)
Ask: "Do you already have a domain on Cloudflare, or do we need to start from scratch?" Branch on the answer.
Phase 1 โ Get the user a domain on Cloudflare
If they don't have one yet:
Beginner hint to share: "When the domain shows status Active in your Cloudflare dashboard, we're good to continue."
If they already have one: skip to Phase 2.
Phase 2 โ Create the API Token
Send them this exact link (it pre-fills the right permissions when possible, and the user can also build it manually):
https://dash.cloudflare.com/profile/api-tokens โ Create Token โ Create Custom Token
Required permissions (tell them to add these three):
- Account โ Cloudflare Tunnel โ Edit
- Zone โ DNS โ Edit
- Zone โ Zone โ Read
Account Resources: their account. Zone Resources: Include All zones (or specifically the domain). TTL: leave default.
After they click Continue to summary โ Create Token โ Cloudflare shows the token once. Tell them: do not paste it in chat.
Phase 3 โ Receive the token securely
Call request_env_input with:
env_vars=[{"key": "CLOUDFLARE_API_TOKEN", "label": "Cloudflare API Token", "required": true}]
reason="Used to create the tunnel and DNS record on your domain. Stored locally in workspace/.env, never echoed in chat."
Wait for the user to submit it via the secure popup. Do not retry-loop if they don't submit immediately โ just wait.
Phase 4 โ Verify token + pick the zone
Run python3 skills/cloudflare-tunnel-publish/scripts/verify.py. It prints:
- Token validity
- The user's account_id (saves to
workspace/.cf_state.json)
- All zones (domains) on the account
If multiple zones, ask the user which domain to use. Save zone_id and zone_name to state.
Phase 5 โ Decide what to publish
Ask the user two things:
- Subdomain (e.g.,
app, demo, www) โ final hostname will be <sub>.<zone_name>. Apex domain (@) is also allowed.
- Local port (e.g.,
8080, 3000). If they say "my Starchild preview", run cat /data/previews.json 2>/dev/null to look up an existing preview's port; otherwise ask explicitly.
Default service URL: http://localhost:<port>.
Phase 6 โ Build the tunnel (automated)
Run python3 skills/cloudflare-tunnel-publish/scripts/setup.py --hostname <full_hostname> --port <port>.
Tunnel reuse is the default. If a healthy tunnel already exists on the
account, setup.py adds the new hostname to that tunnel's ingress and points
the DNS CNAME at it โ instead of creating a separate tunnel. One tunnel =
one cloudflared process = one keepalive watchdog. Creating a new tunnel per
site is how you end up with N processes to babysit and N ways to silently go
dark (the exact bug that prompted this: a second tunnel's cloudflared died
and nothing was watching it, because the watchdog only knew about the first).
Pass --new-tunnel only when you have a reason to isolate traffic (e.g. very
high volume on one site that would saturate the shared edge connections).
The script does, in order:
- Reuse a healthy tunnel if one exists (or create one named
starchild-<hostname> if none, or use --new-tunnel to force a new one)
- Fetch the tunnel run token (a long base64 string used to start
cloudflared)
- PUT the ingress configuration: merge
<hostname> โ http://localhost:<port> into the existing ingress rules (preserving other hostnames), fallback 404
- Create a CNAME DNS record:
<hostname> โ <tunnel_id>.cfargotunnel.com, proxied = true
- Append the site to the
sites array in workspace/.cf_state.json (multi-site support โ see "Multiple sites" below)
If a tunnel with the same name exists, reuse it instead of erroring.
Phase 7 โ Start cloudflared
Run bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh โ this is the
canonical way to bring the site up. keepalive.sh is the single start+heal
brain (see "Keeping it alive" below): it reads .cf_state.json, starts the app
(if you recorded --app-cmd) and the tunnel, and verifies the public URL.
run_tunnel.sh is the lower-level tunnel-only launcher that keepalive.sh
calls โ it downloads cloudflared to workspace/bin/ if missing, reads the
run_token from .cf_state.json, and runs cloudflared tunnel run.
Tell the user the site is up. The SAME keepalive.sh is what you'll wire into
boot + a schedule for durability โ don't hand-roll a separate starter.
Phase 8 โ Verify
โ ๏ธ Do not use the container's curl https://<hostname> directly โ the container's resolver caches stale NXDOMAIN for new domains and will lie to you. Always verify via DoH:
curl -sS "https://dns.google/resolve?name=<hostname>&type=A" | python3 -m json.tool
Three possible outcomes:
Status: 0 + IPs in Answer โ live. Now curl -I https://<hostname> should return 200/301/302. Show the user their URL. ๐
Status: 3 (NXDOMAIN) + Authority = TLD registry NS (e.g. ns.trs-dns.com) โ TLD registry hasn't propagated the new domain yet. Tell the user: configuration is 100% done, wait 30โ60 min (newly registered domains can take up to 24 h), then retry. Don't keep polling โ let them check on their own device.
- Tunnel logs show errors (check
bash_process(action='log', session_id=...)) โ real config bug. Common culprits: ingress not pointing at the right port, local service not running, wrong CNAME target.
Decision rules
-
User says "my service is on my laptop, not in Starchild" โ exact same flow, but Phase 7 must run on their laptop, not in this container. Give them the equivalent install command for their OS:
- macOS:
brew install cloudflared && cloudflared tunnel run --token <TOKEN>
- Linux/Windows: link to https://github.com/cloudflare/cloudflared/releases/latest
Send the run_token via
request_env_input if needed, or just print it once and tell them to copy it (it's safe to share with their own machine, but never paste back to chat).
-
User wants multiple subdomains โ reuse the same tunnel; PUT a new ingress config that lists all hostnames; create one CNAME per hostname. This is now the default behavior โ just run setup.py for each subdomain and it will merge into the existing tunnel's ingress.
-
User wants to remove it โ run python3 skills/cloudflare-tunnel-publish/scripts/teardown.py (deletes DNS + tunnel + kills the local cloudflared process).
Gotchas (โ ๏ธ all confirmed in real runs)
Token / API
Cloudflare-Tunnel:Edit does NOT grant /accounts listing. Calling GET /accounts returns an empty list even with a valid token. Solution: derive account_id from any zone's embedded account.id field โ verify.py already does this. Do NOT add Account:Account Settings:Read just to fix it; the zone trick is cleaner.
- The "run token" from
GET /accounts/{id}/cfd_tunnel/{tunnel_id}/token is what cloudflared tunnel run --token consumes. Do not confuse with:
- tunnel secret โ only relevant for legacy locally-managed tunnels (we don't use)
- API Token โ used to call api.cloudflare.com
Tunnel / Ingress
- The CNAME target must be
<tunnel_id>.cfargotunnel.com, NOT the tunnel name.
- Remotely-managed tunnel (
config_src: "cloudflare") routes via the API config endpoint, NOT a local config.yml. Do not generate one.
- Creating a tunnel via API requires a
tunnel_secret field (32 random bytes, base64) even for config_src=cloudflare. setup.py generates one automatically.
Universal SSL provisioning lag โ the OTHER big trap
After DNS propagates, the user may still hit ERR_SSL_VERSION_OR_CIPHER_MISMATCH in the browser. This is NOT a bug โ Cloudflare hasn't issued the Universal SSL certificate for the new hostname yet.
Diagnosis (run from container โ no auth needed):
echo | timeout 10 openssl s_client -connect <hostname>:443 -servername <hostname> 2>&1 | grep -E "(handshake|peer certificate|Cipher is)"
no peer certificate available + handshake failure โ cert not issued yet โณ
- Real cert returned โ working โ
Timing:
- Established zones with prior certs: usually < 5 min
- Brand-new domains: 15 min ~ 24 h (DNS validation + CA signing + edge propagation)
What to tell the user:
- Open
dash.cloudflare.com โ <domain> โ SSL/TLS โ Edge Certificates
- Look for a row like
*.<domain>, <domain> and check status:
Active โ done, refresh browser
Pending Validation / Initializing โ wait
- Confirm
SSL/TLS โ Overview โ Encryption mode is Full (not Flexible, not Full Strict). Tunnel always carries HTTPS to the origin, so Full is the right match.
Don't: Tell the user to add an Advanced Certificate ($$$) or to change DNS โ neither helps. Just wait.
DNS propagation โ the big trap
Newly registered domains take 30 min ~ 2 h (sometimes up to 24 h) to propagate across the global TLD registry, even when the Cloudflare dashboard shows "Active" instantly. Symptoms:
dig @1.1.1.1 yourdomain.com NS returns NXDOMAIN (Status=3)
- The Authority section shows the TLD's registry NS (e.g.,
ns.trs-dns.com for .fun via Tucows), NOT Cloudflare's NS
cloudflared tunnel is connected and healthy, but https://yourdomain.com returns DNS resolution failure
This is NOT a bug in the skill โ it's TLD registry sync lag. Use the diagnostic snippet below to distinguish it from real issues. Tell the user: "Configuration is complete. Wait 30โ60 minutes and try again. Nothing more to do on our side."
Container DNS โ false negative
When testing from inside the Starchild container, the container's local resolver may not see new domains for hours. Always cross-check with public DoH:
curl -sS "https://dns.google/resolve?name=hello.example.com&type=A" | python3 -m json.tool
Status: 0 + Answer array with IPs โ working โ
Status: 3 (NXDOMAIN) + Authority: ns.trs-dns.com (or similar registry NS) โ TLD propagation pending โณ
Status: 0 but no Answer โ CNAME exists but Cloudflare orange-cloud not yet routing โ wait 30s
Keeping it alive โ one script, two triggers
This is the part agents get wrong. "Publish" is easy; keeping a tunnel site up
for weeks is the real job. The Starchild container restarts without warning
(platform updates, OOM, migration, user reboot), and cloudflared also dies
on its own mid-life (network blip, edge reset, QUIC failure) while the container
keeps running. Either one leaves https://yourdomain.com returning
502 / 521 / 530 / 1033 until something restarts the processes. DNS and the
Cloudflare-side tunnel config survive (they live on Cloudflare's servers) โ only
the local processes need relaunching.
The design: ONE idempotent recovery brain (scripts/keepalive.sh) called from
TWO triggers. Do not write per-project starter/healer scripts โ that's how the
two copies drift apart. keepalive.sh ships with this skill and is generic: it
reads hostname, port, app_cmd, app_dir from .cf_state.json, so the same
file works for any domain. The calling agent writes ZERO project-specific shell.
What keepalive.sh does each run:
- Probe the public URL (reachability, not just PID โ a
cloudflared process
can be alive but disconnected).
- Healthy โ log one line, exit silently.
- Down โ diagnose: local app port closed โ restart app (via
app_cmd) and
tunnel; only the tunnel dead โ restart just the tunnel. Then re-verify with a
few retries (covers cold-start warm-up).
- Report on state transitions only (tracked in
run/keepalive.state):
newly-recovered or newly-failed prints one line; steady-state (healthy, or
already-known-down) is silent. So a scheduled task pushes signal, never spam.
Step 1 โ record how to start the app (at setup time)
Pass --app-cmd / --app-dir to setup.py so keepalive can restart the app,
not just the tunnel:
python3 setup.py --hostname app.example.com --port 8765 \
--app-cmd "python3 server.py" --app-dir projects/myapp
If you omit --app-cmd, keepalive guards the tunnel only and cannot revive a
crashed app. Always record it unless the app is supervised elsewhere.
Step 2 โ start the site
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
Idempotent: starts whatever is down, no-op when healthy.
Step 3 โ survive container restarts (boot trigger)
Add keepalive to workspace/setup.sh (runs on every container boot):
if [ -f /data/workspace/.cf_state.json ]; then
bash /data/workspace/skills/cloudflare-tunnel-publish/scripts/keepalive.sh &
disown
fi
๐ซ NEVER put setup.py in setup.sh. setup.py is config-time: it calls
the Cloudflare API, may rotate the run_token, and overwrites .cf_state.json.
Running it on every boot is wasteful, can hit rate limits, breaks if the API
token was removed, and can change a working config. Boot must only read state
โ that's exactly what keepalive.sh does. The name "setup" tempts you to put
it in "setup.sh"; resist it.
Step 4 โ survive mid-life process death (watchdog trigger)
Schedule the SAME script as a cheap command-mode task. One schedule
guards all sites โ keepalive.sh iterates every site in .cf_state.json
in a single pass, so you never need a per-site watchdog:
scheduled_task(action="schedule",
schedule="every 2 minutes",
command="cd /data/workspace && bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh",
title="cloudflare tunnel keepalive (all sites)")
- Use a relative command with
cd /data/workspace &&. An absolute
/data/workspace/... path can be normalized by the scheduler into a
non-existent /data/skills/... (the workspace/ segment gets dropped), so
every run fails silently. cd + relative path is immune. (Confirmed in a real
run.)
- Keep
deliver at its default so the transition-only alerts actually reach the
user. keepalive is already silent on healthy runs, so there's no spam to
suppress โ and a real outage should ping you.
- The interval may be normalized (e.g. "every 2 minutes" โ 3 min) โ fine.
Verify durability (do all of this before claiming "stable")
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
tail -5 logs/keepalive.log
grep keepalive setup.sh
Don't claim "long-term stable" after only editing setup.sh. That covers
restarts but not mid-life death. Confirm BOTH triggers (boot + schedule) point at
keepalive.sh, and that the first run logged ok https://....
Port collision is a silent failure. Other workspace projects may already hold
common ports (8000/8080/8765). If your app's bind() fails with Address already in use it exits, but curl localhost:<port> still returns 200 โ someone else's
app is answering. Use a high, project-unique port and verify the page is your
content (curl https://yourdomain.com | head), not just a 200.
Tell the user explicitly:
๐ ไฝ ็็ซ็น่ทๅจๅฎนๅจ้ใๅฎนๅจๅฏ่ฝๅ ๆดๆฐ/ๅ
ๅญ/่ฟ็งป้ๆถ้ๅฏ๏ผ้ง้่ฟ็จๅถๅฐไนไผ่ชๅทฑๆ็บฟ๏ผๅๅๅ 502/521/530/1033๏ผใๆๅทฒ็ปๆไธไธช่ชๆ่ๆฌๅ่ฟไบ workspace/setup.sh๏ผๅผๆบ่ชๅจๆ่ตท๏ผๅนถ่ฎพไบๆฏๅ ๅ้ไธๆฌก็ๅทกๆฃ๏ผๆ็บฟ่ชๅจ้ๆใๆขๅค/ๅคฑ่ดฅๆ้็ฅไฝ ๏ผใไธคๅฑ้ฝๆๅๅไธไธช่ๆฌ๏ผไฝ ๅบๆฌไธ็จ็ฎกใ็ๆไธๅผๆถ่ฎฉๆ็ไธ็ผ logs/keepalive.log ๅ logs/cloudflared.log ๅฐฑ่ฝๅฎไฝใ
Plan limits
- Free plan is enough. No upsell needed.
- Free plan only proxies ports 80/443 publicly โ irrelevant to us, since the tunnel always exposes 443 to the world;
localhost:<port> can be anything.
State file format
workspace/.cf_state.json supports multiple sites. The canonical store is
the sites array; the flat top-level fields are kept for backward compat and
always reflect the last-configured site:
{
"account_id": "...",
"zone_id": "...",
"zone_name": "example.com",
"sites": [
{
"hostname": "app.example.com",
"port": 8080,
"tunnel_id": "...",
"tunnel_name": "starchild-app-example-com",
"run_token": "...",
"app_cmd": "python3 server.py",
"app_dir": "/data/workspace/projects/myapp"
},
{
"hostname": "blog.example.com",
"port": 3000,
"tunnel_id": "...",
"run_token": "...",
"app_cmd": "",
"app_dir": ""
}
],
"hostname": "blog.example.com",
"port": 3000,
"tunnel_id": "...",
"run_token": "...",
"app_cmd": "",
"app_dir": ""
}
keepalive.sh reads sites[] and guards every site in one pass โ one
watchdog, all hostnames. This is the fix for the "each site got its own tunnel
- its own keepalive, and only one was watched" bug: there is now exactly one
keepalive process that knows about every configured site.
Multiple sites โ the design
One tunnel, many hostnames, one watchdog. This is the only sane topology:
setup.py defaults to reusing the first healthy tunnel it finds on the
account. It merges the new hostname into the tunnel's existing ingress rules
(preserving other hostnames) and points the DNS CNAME at that tunnel.
.cf_state.json holds a sites[] array. Each setup.py run appends (or
replaces) one entry. teardown.py removes one entry.
keepalive.sh iterates sites[] and guards every site. One process, one
watchdog, all hostnames. If a site shares a tunnel with another, the same
cloudflared process serves both โ keepalive won't start a second one.
When the agent gets this wrong (the bug that prompted this section):
creating a new tunnel per site means N cloudflared processes, N PID files,
N watchdogs to wire โ and in practice only one watchdog ever gets set up. The
other tunnels silently die and nobody notices for weeks. Reuse the tunnel,
append to sites[], let one keepalive guard them all.