Turn any local port into a public HTTPS URL — on your own domain.
Install · Quick start · Commands · Cookbook · Troubleshooting · Website · العربية
linko is a command-line tool that gives a locally running service a public HTTPS address on a domain you own. You type one command; anyone in the world can then open your work in a browser.
$ npm run dev # your app is on :3000
$ linko 3000 # in another terminal
✓ DNS record created (x92ka.example.com)
✓ Route published (x92ka.example.com -> http://localhost:3000)
✓ Tunnel connected
Public URL https://x92ka.example.com
Forwards http://localhost:3000
Tunnel example-linko-tunnel
Press Ctrl+C to stopIt does this by driving Cloudflare Tunnel for you. What you would otherwise do by hand — create a DNS record, configure a reverse proxy, obtain a certificate, open a port on your router — linko does through the Cloudflare API in about two seconds, and undoes just as cleanly.
What it is not: not a hosting service — your code keeps running on your machine. Not a paid tunnel provider — it uses your Cloudflare account on the free plan. Not a production deployment tool — it is built for development, demos, webhooks and sharing.
| Typical tunnel tool | linko | |
|---|---|---|
| The domain | Theirs | Yours |
| URL after a restart | A new random one | The same one |
| Warning page for visitors | Often | Never |
| Session limits | On the free tier | None |
| Survives a reboot | Manual | linko service install |
| Runs on | Their infrastructure | Cloudflare's network, your account |
| Your domain | Every URL lives under a domain you control. |
| Stable URLs | A port keeps its URL. Restarting your app does not change the link you shared. |
| Automatic HTTPS | Cloudflare issues and renews the certificate. |
| One binary | Written in Go, statically linked. No Node, no Python, no runtime. |
| One tunnel, many projects | Each project is a hostname routed to a different local port. |
| Runs anywhere | Foreground, background, or started automatically at every boot. |
| Repairs itself | A dead token or a deleted tunnel is explained and fixed, not just reported. |
This is the case linko was built for. A home server sits behind CGNAT or a dynamic address: port forwarding is either blocked by the ISP or breaks the next time the lease renews. The Raspberry Pi in the corner, the old laptop, the NAS — none of them can be reached from outside, however good the domain you own.
linko never asks for an inbound port. cloudflared dials out from your
machine, so the only address the world ever sees is Cloudflare's. Your IP stays
private and your router stays untouched.
What that replaces, per project:
| By hand | With linko |
|---|---|
| Create a Zero Trust tunnel and connect it | linko 8096 --name media |
| Write ingress rules, keep the catch-all last | — |
Add a proxied CNAME to the right zone |
— |
| Sort out the certificate, then discover a two-level name is not covered | — |
| Write a unit file so it survives a reboot | linko service install |
Every other project on that machine joins the same tunnel — a hostname each, pointed at a different local port.
| Required | Notes | |
|---|---|---|
| Cloudflare account | ✅ | Free plan is enough — sign up |
| A domain you own | ✅ | Bought anywhere: Namecheap, GoDaddy, Google, anywhere |
| Its DNS managed by Cloudflare | ✅ | See below — free, and you keep your registrar |
cloudflared |
❌ | Downloaded automatically on first use |
| Go | ❌ | Only if you build from source |
| Node, Python, Docker | ❌ | Not used at all |
Important
Owning a domain is not enough — its nameservers must point at Cloudflare.
linko creates DNS records through the Cloudflare API, so Cloudflare has to
be the authoritative DNS for that domain. This is free and takes about ten
minutes, most of it waiting.
Moving a domain's DNS to Cloudflare (free, keeps your registrar)
You are not transferring ownership. The domain stays registered where you bought it, you keep paying the same registrar, and you can undo this any time. Only the nameservers change.
-
Create a free account at dash.cloudflare.com/sign-up.
-
Add your domain. Dashboard → Add a site → type
example.com→ choose the Free plan. -
Let it scan. Cloudflare copies your existing DNS records automatically. Check the list against your current provider — especially
MXrecords if you use email on that domain, or your mail will stop. -
Copy the two nameservers Cloudflare shows you, for example:
dana.ns.cloudflare.com rick.ns.cloudflare.com -
Paste them at your registrar. Sign in where you bought the domain, find Nameservers / DNS settings / Custom DNS, remove what is there, and put Cloudflare's two in. Registrar-specific guides: Namecheap · GoDaddy · Google Domains / Squarespace · Hostinger
-
Wait. Usually under an hour, occasionally up to 24. Cloudflare emails you when the domain becomes Active.
Check it yourself at any time:
dig NS example.com +short # should list *.ns.cloudflare.comOnce the domain shows Active in the dashboard, run linko init.
Do not have a domain yet? Cloudflare sells them at cost with DNS already set up — nothing to move.
curl -fsSL https://raw.githubusercontent.com/Devehab/linko/main/install.sh | bashThe script detects your platform, downloads the matching binary, and puts it in a
directory already on your PATH. If it has to fall back to ~/.local/bin, it
adds that line to your shell profile for you.
linko --versionOther ways to install
Homebrew-managed Go, or any Go 1.23+
go install github.com/Devehab/linko@latestFrom source
git clone https://github.com/Devehab/linko.git
cd linko
make deps && make verify && make buildWindows without Git Bash — download the .zip from
Releases, unzip it, and put linko.exe
on your PATH. PowerShell and CMD cannot run a curl … | bash line.
Pin a version or a directory
LINKO_VERSION=v0.3.0 LINKO_INSTALL="$HOME/bin" \
bash -c "$(curl -fsSL https://raw.githubusercontent.com/Devehab/linko/main/install.sh)"Go to dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token.
Add both permission rows to the same token — press + Add more for the second:
| # | Type | Resource | Permission |
|---|---|---|---|
| 1 | Zone |
DNS |
Edit |
| 2 | Account |
Cloudflare Tunnel |
Edit |
Then set the resources:
- Account Resources → your account
- Zone Resources →
Include→Specific zone→ your domain
Warning
Leaving Zone Resources empty produces a token that authenticates
successfully but sees no domains at all, and linko init fails with
no zone named …. This is the single most common setup mistake.
Not sure? linko docs prints these steps in your terminal.
linko initPaste the token, then pick a domain by number — linko lists the ones your token can actually see, so there is nothing to type or misspell.
Cloudflare credentials
Cloudflare API token: ················
✓ Cloudflare connected
Domain
Which domain should linko use?
1. example.com
2. another.dev
3. staging.io (pending)
Choose: 1
✓ URLs will look like https://abc12.example.com
Tunnel
Tunnel name: [example-linko-tunnel]
✓ Tunnel created
✓ Configuration saved to ~/.linko/config.json
✓ cloudflared installed
You're ready.The domain you pick is the base — there is no second question, and that is what keeps every URL one label deep, which is all a free Cloudflare certificate covers. See the one-level rule.
npm run dev # your app, on :3000
linko 3000 # in another terminalThis is the behaviour that makes linko pleasant to live with.
linko 3000 # first run → https://x92ka.example.com
# Ctrl+C
linko 3000 # next run → https://x92ka.example.com (the same one)A link you already shared keeps working across restarts, and your DNS does not fill up with dead records. When you do want to change it:
linko 3000 --new # mint a fresh random URL, retire the old one
linko 3000 --name crm # choose the name yourself
linko 3000 --temp # throwaway: deleted the moment you quit| Flag | Effect |
|---|---|
--no-color |
Disable coloured output |
--version |
Print the version |
-h, --help |
Help for any command |
One-time setup: verify the token, find the DNS zone, create the tunnel, save
everything to ~/.linko/config.json with mode 0600.
| Flag | Effect |
|---|---|
--token <t> |
Cloudflare API token (or set LINKO_API_TOKEN) |
--domain <d> |
Skip the picker and use this domain |
--base <b> |
Publish under a deeper subdomain — needs a paid certificate |
--tunnel <n> |
Tunnel name (default <domain>-linko-tunnel) |
--force |
Overwrite an existing configuration |
-y, --yes |
Non-interactive: fail instead of prompting |
--skip-download |
Do not download cloudflared now |
# fully non-interactive, for CI or a fresh machine
export LINKO_API_TOKEN='cfut_…'
linko init --yes --domain example.comPublish a local service. linko 3000 is shorthand for linko start 3000.
| Flag | Effect |
|---|---|
-n, --name <n> |
Subdomain to use (default: reuse this port's URL) |
-r, --new |
Mint a new random subdomain, retiring this port's current one |
--temp |
Delete the hostname when the tunnel stops |
-d, --detach |
Run in the background and return to the prompt |
-o, --open |
Open the URL in your browser once it answers |
--replace |
Replace the hostname if it already points somewhere else |
-y, --yes |
Do not ask questions |
-v, --verbose |
Stream cloudflared logs |
--loglevel <l> |
debug · info · warn · error · fatal |
Accepted targets
| You type | It means |
|---|---|
linko 3000 |
http://localhost:3000 |
linko :3000 |
http://localhost:3000 |
linko 127.0.0.1:8080 |
a specific address |
linko https://localhost:8443 |
an origin that speaks HTTPS |
linko tcp://localhost:22 |
a raw TCP service |
Show what you have published.
$ linko list
NAME URL TARGET KIND
api https://api.example.com -> http://localhost:8080 persistent
web https://web.example.com -> http://localhost:3000 persistent
· 2 route(s) · tunnel example-linko-tunnel · ~/.linko/config.json| Flag | Effect |
|---|---|
--remote |
Read the live routes from Cloudflare instead of the local file |
Background tunnels.
$ linko ps
NAME URL TARGET PROCESS
web https://web.example.com -> http://localhost:3000 pid 41288linko stop web # stop one
linko stop --all # stop everythingTunnel health, live edge connections, published routes, and anything running in the background.
$ linko status
Cloudflare Tunnel
Name example-linko-tunnel
ID 8f3c1a92-…
Account Acme
Domain example.com
Status connected (4 connections via AMS, FRA)
Routes
· https://web.example.com -> http://localhost:3000Delete a hostname: the tunnel route, the DNS record, and the local entry.
| Flag | Effect |
|---|---|
--all |
Remove every route linko knows about |
-y, --yes |
Skip the confirmation |
Note
linko never deletes a DNS record that does not point at a Cloudflare
tunnel, so it cannot clobber your production records by accident.
Keep a tunnel running across reboots — a launchd agent on macOS, a systemd
user unit on Linux, both with automatic restart.
linko service install 3000 --name crm
linko service list
linko service uninstall crmEight checks in order, from cloudflared being present to live edge
connections. Exits non-zero on failure, so it works inside scripts.
| Flag | Effect |
|---|---|
--fix |
Repair a dead token, a deleted tunnel, missing DNS records and routes |
-y, --yes |
Repair without asking |
$ linko doctor
✓ cloudflared installed (2025.2.0)
✓ config valid (~/.linko/config.json)
✓ API token valid
✓ DNS zone reachable (example.com)
✓ tunnel exists (example-linko-tunnel)
✓ tunnel configuration readable (2 routes)
✓ DNS configured for 2 routes
✓ connection active (4 edge connections)
Everything looks good.Change which of your domains linko publishes to, from the same numbered list.
$ linko domain
Currently publishing to example.com
Which domain should linko publish to?
1. example.com (current)
2. another.dev
Choose: 2
! You have 2 URLs on example.com:
https://web.example.com -> http://localhost:3000
https://api.example.com -> http://localhost:8080
Delete them before switching? [Y/n] y
✓ Removed web.example.com
✓ Removed api.example.com
✓ Now publishing to another.dev| Flag | Effect |
|---|---|
--list |
Show the domains this token can see and exit |
-y, --yes |
Switch without asking |
linko domain another.dev switches straight to one. A domain in a different
Cloudflare account is handled too — linko sets up a tunnel there.
Replace the stored API token — after a rotation, an expiry, or a permission fix.
$ linko token
New Cloudflare API token: ················
✓ Token updated and saved to ~/.linko/config.json
✓ Can reach example.com
✓ Can reach the tunnel example-linko-tunnel
All good.The new token is checked before it is saved, so a typo cannot lock you out
of your own configuration. --token cfut_… for non-interactive use.
Remove linko from the machine, and its traces from Cloudflare, in order:
- stop tunnels running in the background
- remove the services that start them at login
- delete your published URLs and their DNS records
- delete the tunnel from your Cloudflare account
- delete
~/.linko— config, logs, thecloudflaredcopy - delete the
linkobinary itself
| Flag | Effect |
|---|---|
-y, --yes |
Do not ask for confirmation |
--keep-cloud |
Leave the URLs, DNS records and tunnel on Cloudflare |
--keep-binary |
Leave the binary in place |
Your Cloudflare account, your domain and your API token are never touched — revoke the token yourself if you no longer want it.
The setup guide in your terminal. --open launches the full page in a browser.
Show a client your work in progress
linko 3000 --name preview --openTwo services at once, one tunnel
linko 3000 -n web # terminal 1
linko 8080 -n api # terminal 2A staging box that must survive reboots
linko service install 8080 --name staging
linko service listA webhook endpoint you can leave running all week
linko 4000 -n hooks -d
linko psA one-off link that cleans up after itself
linko 5173 --tempYour app only listens on the loopback interface
linko 127.0.0.1:3000Rotate a URL you accidentally shared too widely
linko 3000 --newCloudflare's free Universal SSL covers exactly two names:
example.com the apex
*.example.com one level
A wildcard in a certificate does not cross a dot:
| URL | Works? |
|---|---|
demo.example.com |
✅ matches *.example.com |
demo.app.example.com |
❌ two levels — no free certificate covers it |
Caution
This is the hardest failure to diagnose in the whole system. DNS is correct,
the tunnel is connected, and linko doctor is entirely green — yet the
browser refuses with ERR_SSL_VERSION_OR_CIPHER_MISMATCH, because the
rejection happens before any request reaches your tunnel. linko init warns
you about it up front.
Need two levels? That requires Cloudflare Advanced Certificate Manager ($10/month per zone) and then Total TLS. The free alternatives — subdomain zones and partial CNAME setups — are limited to Enterprise and Business plans.
Simplest free answer: put the distinction in the name itself —
myapp-dev.example.com rather than myapp.dev.example.com.
Everything lives in ~/.linko/:
~/.linko/
├── config.json credentials and routes (mode 0600)
├── bin/cloudflared private copy, downloaded on first use
├── run/<name>.pid one per background tunnel
└── logs/<name>.log one per background tunnel
| Variable | Effect |
|---|---|
LINKO_API_TOKEN |
Overrides the stored Cloudflare token |
LINKO_HOME |
Configuration directory (default ~/.linko) |
NO_COLOR |
Disable coloured output |
linko creates a single Cloudflare Zero Trust tunnel in your account. Each
project becomes a hostname inside that tunnel, routed to a different local port.
crm.example.com ──┐
│ ┌──▶ localhost:3000
api.example.com ──┼──▶ Cloudflare Edge ─┼──▶ localhost:8080
│ (one tunnel) │
test.example.com ──┘ └──▶ localhost:5173
▲
│ outbound connection only
cloudflared on your machine
Running linko 3000:
- creates a proxied
CNAMEpointing at<tunnel-id>.cfargotunnel.com - adds an ingress rule mapping the hostname to
http://localhost:3000 - starts
cloudflared, which opens an outbound connection to Cloudflare
Nothing on your machine listens for inbound connections, which is why no firewall or router change is ever needed.
| Message | Cause | Fix |
|---|---|---|
no zone named "example.com" |
Token is missing the Zone permission, or its Zone Resources are empty |
Add Zone → DNS → Edit and include your domain. linko lists the zones the token can see. |
Cloudflare refused to create the DNS record code 10000 |
DNS permission is Read, not Edit |
Finding the zone only needs read access, so setup passes and the first write fails. Switch it to Edit. |
Could not create the tunnel code 10000 |
Token covers DNS but not tunnels | Add Account → Cloudflare Tunnel → Edit to the same token. |
ERR_SSL_VERSION_OR_CIPHER_MISMATCH |
Hostname is two levels deep | linko init --force and pick the domain itself, or buy ACM. |
no API token: pass --token or set LINKO_API_TOKEN |
linko init --yes with no token |
export LINKO_API_TOKEN='…' or pass --token. |
| Token expired or was rotated | The stored token no longer authenticates | linko token — the new one is checked before it is saved. |
| You want to publish to a different domain | Your setup points at the old one | linko domain — pick the new one from the list. |
command not found: linko |
Install directory is not on PATH |
Reopen the terminal, or run exec $SHELL. |
HTTP 502 · Error 1033 |
Tunnel is up but nothing is listening | Check curl http://localhost:3000. If your app binds the loopback only, use linko 127.0.0.1:3000. |
Tokens expire. People delete tunnels from the Zero Trust dashboard. DNS records
get removed by hand. linko detects all three, explains what happened in plain
terms, and repairs it — it never just prints a Cloudflare error code.
The token stopped working
$ linko 3000
✗ Cloudflare is no longer accepting the stored API token.
· It was most likely deleted, edited, or it expired.
Create a Cloudflare API token
1. https://dash.cloudflare.com/profile/api-tokens
2. Add both permission rows (+ Add more):
Zone → DNS → Edit
Account → Cloudflare Tunnel → Edit
New Cloudflare API token: ················
✓ Token updated and saved to ~/.linko/config.json
✓ Tunnel connectedThe new token is verified against Cloudflare before it is saved, so a typo cannot lock you out of your own configuration.
The tunnel was deleted
linko recreates it (or adopts one of the same name), fetches a fresh tunnel
token, re-points every DNS record at the new tunnel — a new tunnel means a
new CNAME target — and restores every route.
A DNS record was removed
Recreated on the next linko <port>, or for all of them at once:
linko doctor --fix--fix repairs a dead token, a deleted tunnel, missing DNS records and missing
routes, then re-runs the full check. Add --yes to skip the confirmations.
When in doubt:
linko doctorlinko remove --all --yes
rm -rf ~/.linko
linko initTo remove the tool itself — background tunnels, login services, URLs, tunnel and
binary — use linko uninstall.
Coding agents get this wrong in predictable ways: linko 3000 blocks forever
without -d, ~/.linko/config.json holds live credentials next to the data you
want, and a command exiting 0 is not proof the URL answers. So there is a skill
that says all of that up front.
# Claude Code / Cowork
mkdir -p ~/.claude/skills && cp -r skills/linko ~/.claude/skills/
# Codex, Cursor, Windsurf, Aider — append it to the agent's instruction file
curl -fsSL https://raw.githubusercontent.com/Devehab/linko/main/skills/linko/SKILL.md >> AGENTS.mdskills/linko/SKILL.md · how to install it
- Published URLs are public. Anyone with the link reaches your local service. Do not expose anything sensitive; put Cloudflare Access in front of the hostname if you need authentication.
- The token lives in
~/.linko/config.jsonwith mode0600. Never commit it. For teams and CI, useLINKO_API_TOKENinstead. - The tunnel token is passed to
cloudflaredvia the environment, never on the command line, so it does not appear inps. - Foreign DNS records are never touched. If a hostname points at your real
site,
linkorefuses to overwrite or delete it.
make deps # go mod tidy
make test # run the test suite
make race # tests with the race detector
make cover # coverage report
make verify # gofmt + go vet + tests
make build # ./linko
make release # cross-compiled archives in dist/
make e2e # full end-to-end run against a real Cloudflare accountmain.go entry point
cmd/ Cobra commands
init.go setup wizard and permission diagnostics
start.go publish, sticky names, DNS + ingress
background.go detach, pid files, process control
service.go launchd / systemd units
list.go status.go remove.go doctor.go docs.go stop.go
cloudflare/ Cloudflare API client — net/http only
api.go auth, accounts, zones
tunnel.go tunnels and ingress rules
dns.go CNAME records
config/config.go ~/.linko/config.json
internal/cloudflared/ locate, download and run cloudflared
internal/naming/ subdomain generation and validation
internal/target/ "3000" -> "http://localhost:3000"
internal/ui/ coloured output, prompts, OSC 8 hyperlinks
- The tunnel is remotely managed (
config_src: cloudflare) — no local YAML to keep in sync. - The ingress list is normalised before every write: exactly one catch-all rule, always last.
- The config file is written atomically (temp file + rename) with mode
0600. - One dependency: Cobra. Everything else is the standard library.
- Every URL is emitted as an OSC 8 hyperlink where the terminal supports it, and as plain text where it does not.