Install
viaduct needs Python 3.11 or newer and
pipx,
which keeps the CLI in its own isolated environment and on your
PATH.
$ pipx install viaduct-sh
That installs both the viaduct client and the
viaductd server. Confirm it is on your path:
$ viaduct --version
Start a tunnel
Point viaduct http at any local port. It prints a public
HTTPS URL and forwards traffic to that port until you stop it.
- Run your app on some local port, for example a dev server on
8080. - Open the tunnel with the command below.
- Share the printed URL. Every request to it rides the tunnel to your
localhost.
$ viaduct http 8080
✓ tunnel live https://sunny-otter.viaduct.sh → http://localhost:8080 press q to stop
502 page until your app comes up, so
you can start them in either order.
Stop the tunnel with q or Ctrl+C. On a drop it
reconnects on its own, and by default each reconnect gets a fresh URL.
To keep the same one, pin it.
Keep the same URL (--pin)
Add --pin to keep the same public URL for a given port
across reconnects and restarts. Useful for a webhook endpoint you
register once, or a demo link you want to stay put.
$ viaduct http 8080 --pin
✓ tunnel live https://amber-stoat-9f3ac41b2c7d.viaduct.sh → http://localhost:8080 press q to stop
--pin the name is server-assigned. It is
derived from a random secret stored once at
~/.config/viaduct/pin.key; different ports get different
stable names, and deleting that file rotates to a new one. To choose the
string yourself, use --name below.
Choose a name (--name)
Want a specific subdomain rather than a random one? Pass
--name. If it is free and passes a quick check, the server
assigns it and pins it, so it stays put across reconnects, like
--pin but with a name you picked.
$ viaduct http 8080 --name myapp
✓ tunnel live https://myapp.viaduct.sh → http://localhost:8080 press q to stop
a-z, 0-9 and hyphens
(no leading or trailing hyphen). Reserved and unsuitable names are
refused. Availability is per server, so if a name is already taken on the
one you connect to you will be told, pick another or try a
--region. --name and
--pin cannot be combined.
Inspect requests (--inspect)
The traffic inspector prints each request as it happens: method, path,
status, and timing. Ideal for debugging webhooks without leaving your
terminal. Start it with --inspect:
$ viaduct http 8080 --inspect
✓ tunnel live https://funny-otter.viaduct.sh → http://localhost:8080 14:02:01 GET / 200 5ms 14:02:04 POST /webhooks/stripe 200 61ms 14:02:06 GET /favicon.ico 404 1ms 14:02:09 GET /ws 101 3ms
You do not have to decide up front. Press i on a running
tunnel to turn the inspector on or off at any time, with no reconnect
and no change to your URL. Starting with --inspect just
has it on from the first request.
For a full walkthrough of pointing a provider at your machine, see receiving webhooks on localhost.
Combine flags
Flags stack. Pin a stable URL and watch its traffic at the same time:
$ viaduct http 8080 --pin --inspect
The order does not matter, and the local port can be any port your app listens on.
Protect a tunnel (--basic-auth, --allow-ip)
A tunnel is public by default: anyone with the URL reaches your local app. Put a gate in front of it with a password, a bearer token, an IP allowlist, or any combination.
$ viaduct http 3000 --basic-auth alice:secret
That's an HTTP Basic prompt in the browser. Omit the :secret
and you're asked for the password at the terminal instead of typing it
into the command line. For APIs and webhooks, require a token:
$ viaduct http 3000 --bearer $TOKEN
Or restrict by address. Repeat --allow-ip or comma-separate; each is a single IP or a CIDR range:
$ viaduct http 3000 --allow-ip 203.0.113.4 --allow-ip 10.0.0.0/8
Keep credentials out of your shell history with an environment variable
(VIADUCT_BASIC_AUTH, VIADUCT_BEARER) or a line
in ~/.config/viaduct/config.toml. Change the pages visitors
see with --auth-message (401) and --deny-message
(403), and the browser prompt's label with --auth-realm.
What a visitor without credentials sees, served by viaductd itself. Change the wording with --auth-message.
Security and privacy
viaduct is a raw byte splice. The server reads only the head of each request
(the request line and headers) to find the Host it should route
to, then splices the bytes both ways untouched. Request and response bodies
are never parsed or buffered, and WebSocket upgrades pass straight through.
It is a pipe, not a proxy that reads your traffic.
What leaves your machine
The tunnel carries your app's traffic to whoever holds the URL, so treat a
public tunnel like any public link. The one thing never sent in the clear is
your credentials: with --basic-auth or --bearer,
only a hash of the password or token leaves your machine, never the plaintext.
Auth is checked at the edge
viaductd verifies --basic-auth, --bearer and
--allow-ip before it opens a connection to you, so an
unauthorised visitor gets a clean 401 or 403 and never reaches your app. The
flags are in Protect a tunnel.
The inspector reads the line, not the body
--inspect peeks at the first packet of
each request and response to read the method, path and status code, once,
and prints a line. It never buffers the body, it is off by default, and it
costs nothing when off.
viaductd and your traffic never
touches viaduct.sh. See the self-hosting guide.
Access control is for sharing a link with the right people, not a shield
against denial-of-service traffic.
Custom domains (bring your own)
Serve a tunnel on your own domain with a single CNAME. There's no extra flag, the mapping lives in your DNS.
- Pin the tunnel so it has a stable name:
viaduct http 8080 --pingives you something likefunny-otter-9a1c2b3d4e5f.viaduct.sh. - Add a CNAME at your registrar (the exact screen differs per provider): point
demo.example.comat thatfunny-otter-9a1c2b3d4e5f.viaduct.shname. - Open it. The first request auto-provisions an HTTPS certificate, then
https://demo.example.comserves your local app.
*.viaduct.sh name rather than straight at an IP. And because
the certificate is only issued once your domain already points here, you
can only serve domains whose DNS you actually control.
Regions
viaduct.sh operates tunnel nodes in five regions. Run your tunnel on
the one nearest your traffic with --region; with no flag
you get London by default.
| Region | --region | Tunnel URL |
|---|---|---|
lon | name.viaduct.sh | |
nyc | name.nyc.viaduct.sh | |
sg | name.sg.viaduct.sh | |
syd | name.syd.viaduct.sh | |
blr | name.blr.viaduct.sh |
$ viaduct http 8080 --region nyc # -> name.nyc.viaduct.sh
$ viaduct http 8080 --region sg # -> name.sg.viaduct.sh
List them any time with viaduct regions.
All options
Every flag on viaduct http. One worth calling out:
--host-header. Some dev servers (Vite, Next.js, Django,
Rails) keep an allow-list of hostnames and reject a request whose
Host is the public tunnel URL ("Invalid Host header" /
"Blocked request"). --host-header localhost makes your app
see the request as if it arrived on localhost, while
visitors still use the public URL.
| Option | Default | Description |
|---|---|---|
--server host:port | viaduct.sh:4443 | The viaductd server to dial. |
--region lon|nyc|sg|syd|blr | none | Pick a server region; a shortcut for --server. |
--pin | off | Keep the same public URL across reconnects (server-assigned stable subdomain). |
--name SUBDOMAIN | none | Request a specific subdomain, pinned if it is free. Validated server-side; 3 to 63 chars of a-z, 0-9, hyphens. Not combinable with --pin. |
--host-header HOST | off | Rewrite the Host header your app sees (e.g. localhost), for dev servers that reject unknown hosts. |
--basic-auth USER:PASS | off | Require HTTP Basic auth, checked at the edge. Omit :PASS to be prompted for it. |
--bearer TOKEN | off | Require an Authorization: Bearer TOKEN header. |
--allow-ip CIDR | off | Only allow these addresses (single IPs or CIDR ranges). Repeat the flag or comma-separate. |
--auth-message TEXT | default | Message on the 401 page when credentials are missing or wrong. |
--deny-message TEXT | default | Message on the 403 page when an address is not on the allowlist. |
--auth-realm NAME | viaduct | Realm shown in the browser's Basic-auth prompt. |
--inspect | off | Log each request: method, path, status, and time. |
--pool-size N | 40 | Idle data connections kept ready for incoming requests. |
--tls / --no-tls | on, off for localhost | TLS to the tunnel port. |
--tls-ca PATH | none | Extra CA bundle to trust (for a self-signed server). |
Flags can also live in ~/.config/viaduct/config.toml instead of the command line:
server = "viaduct.sh:4443"
tls = true
Stop and update
Stop a running tunnel with q or Ctrl+C. It drains in-flight requests before exiting.
Update to the latest release at any time:
$ viaduct upgrade
viaduct upgrade reinstalls the latest release from PyPI.
On an interactive terminal the client also shows a one-line notice, at
most once a day, when a newer release exists.
List and stop tunnels
Running several at once? viaduct list shows the tunnels
open on this machine, and viaduct kill stops them
(gracefully, so in-flight requests finish) from any shell.
$ viaduct list
NAME PORT URL UP PID funny-otter 8080 https://funny-otter.viaduct.sh 2m 41821 mighty-coati 3000 https://mighty-coati.viaduct.sh 30s 41850
$ viaduct kill funny-otter # by name, or pid $ viaduct kill --all # stop every tunnel on this machine
These are machine-local: they only see tunnels you started on this box, and never touch anyone else's.
From your code
You do not need the CLI. Install viaduct-sh and open a
tunnel straight from Python, handy for scripts and for tests that need
a real public URL (webhooks).
import viaduct
async with viaduct.tunnel(8080) as t: # async
print(t.url) # https://funny-otter.viaduct.sh
with viaduct.tunnel_sync(8080) as t: # sync (scripts, notebooks)
print(t.url)
A public URL in your tests
Installing viaduct-sh also registers a pytest fixture,
viaduct_tunnel, that hands a test a real public URL and
tears it down afterwards. Ideal for exercising inbound webhooks in CI.
def test_stripe_webhook(viaduct_tunnel):
url = viaduct_tunnel(8000) # your local test server is now public
stripe.WebhookEndpoint.create(url=url + "/hook")
... # torn down automatically
Node
There is a zero-dependency Node client too, published as
viaduct-sh on npm and living in the
repo.
import { tunnel } from "viaduct-sh";
const t = await tunnel(3000);
console.log(t.url); // or, no install: npx viaduct-sh 3000
Run your own server
The client defaults to the hosted viaduct.sh, but the point
of viaduct is that you run your own. One Ubuntu droplet runs Caddy
(public HTTPS) and viaductd, and one script sets it all up.
Then point the client at it:
$ viaduct http 8080 --server your-domain.com:4443
--allow-ip works out of the box. If you run
viaductd with no such trusted front, start it with
--trust-peer-ip so it reads the direct connection's address
instead. Only turn that on when a front you control is not adding the
forwarded-for header, otherwise a client could spoof its own address.
Troubleshooting
The pages viaductd serves name the problem, and most come down to a handful of causes. Here is what each one means and how to clear it.
404, tunnel not found
The address has no live tunnel behind it: the tunnel closed, or the link is
an old one. Start it again. By default each run gets a fresh URL, so use
--pin or --name
to keep a stable one.
502, nothing is running here
The tunnel is up, but nothing is listening on the local port it forwards to. Start your app on that port and refresh. viaduct opens the tunnel even before your app is up, so the order you start them in does not matter.
503, tunnel busy
The tunnel ran out of free connections for a moment. The page carries a
Retry-After and refreshes itself, so it usually clears on its own
within a few seconds.
401 or 403 on a protected tunnel
The visitor has no valid credentials (401) or their address is not on the
allowlist (403). Share the password or token, or add their IP with
--allow-ip. Both are checked at the edge,
so neither reaches your app.
That name is not allowed, or already in use
The --name you asked for is reserved or is
already taken on the server you reached. Pick another, or try a different
--region, availability is per server.
Your client is too old for this server
Run viaduct upgrade and reconnect.
The URL keeps changing on reconnect
On a dropped connection viaduct reconnects on its own, and by default each
reconnect gets a fresh URL. Pin it with --pin
or --name to keep the same one across drops.
The first request to your own server is slow, or will not connect
On a server you run, the first request to a new subdomain provisions its TLS
certificate, which takes a few seconds, then it is instant. If it never
connects, check the client can reach the server's host:port and
that wildcard DNS points at the box. See the
self-hosting guide.
