Documentation

Getting started

Everything you need to put a local port on a public URL. Install once, then start a tunnel, pin a stable URL, or watch requests live. Every command below is copy-paste ready.

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.

  1. Run your app on some local port, for example a dev server on 8080.
  2. Open the tunnel with the command below.
  3. Share the printed URL. Every request to it rides the tunnel to your localhost.
$ viaduct http 8080
bash
 tunnel live

  https://sunny-otter.viaduct.sh
  → http://localhost:8080
  press q to stop
Nothing listening yet? viaduct still opens the tunnel and serves a friendly 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
bash
 tunnel live

  https://amber-stoat-9f3ac41b2c7d.viaduct.sh
  → http://localhost:8080
  press q to stop
The name is still assigned by the server. You cannot choose the string, so no squatting on someone else's brand. It is derived from a random secret stored once at ~/.config/viaduct/pin.key. Different ports get different stable names; delete that file to rotate to a new one.

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
bash
 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.

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.

Checked at the edge, not on your machine. viaductd verifies every request before it opens a connection to you, so an unauthorised visitor gets a clean 401 or 403 and never reaches your app. Only hashes of your password and token ever leave your machine, never the plaintext. This is access control 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.

  1. Pin the tunnel so it has a stable name: viaduct http 8080 --pin gives you something like funny-otter-9a1c2b3d4e5f.viaduct.sh.
  2. Add a CNAME at your registrar (the exact screen differs per provider): point demo.example.com at that funny-otter-9a1c2b3d4e5f.viaduct.sh name.
  3. Open it. The first request auto-provisions an HTTPS certificate, then https://demo.example.com serves your local app.
Why a CNAME, not an A record? viaductd follows the CNAME back to your tunnel to know where to route, so it has to point at the *.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--regionTunnel URL
London (default)lonname.viaduct.sh
New Yorknycname.nyc.viaduct.sh
Singaporesgname.sg.viaduct.sh
Sydneysydname.syd.viaduct.sh
Bangaloreblrname.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.

On latency: a tunnel always ends at your machine, so a region mainly helps when your visitors and your machine are both far from the default. Pick the node nearest your traffic; there's no benefit to switching if London is already close.

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.

OptionDefaultDescription
--server host:portviaduct.sh:4443The viaductd server to dial.
--region lon|nyc|sg|syd|blrnonePick a server region; a shortcut for --server.
--pinoffKeep the same public URL across reconnects (stable subdomain).
--host-header HOSToffRewrite the Host header your app sees (e.g. localhost), for dev servers that reject unknown hosts.
--basic-auth USER:PASSoffRequire HTTP Basic auth, checked at the edge. Omit :PASS to be prompted for it.
--bearer TOKENoffRequire an Authorization: Bearer TOKEN header.
--allow-ip CIDRoffOnly allow these addresses (single IPs or CIDR ranges). Repeat the flag or comma-separate.
--auth-message TEXTdefaultMessage on the 401 page when credentials are missing or wrong.
--deny-message TEXTdefaultMessage on the 403 page when an address is not on the allowlist.
--auth-realm NAMEviaductRealm shown in the browser's Basic-auth prompt.
--inspectoffLog each request: method, path, status, and time.
--pool-size N40Idle data connections kept ready for incoming requests.
--tls / --no-tlson, off for localhostTLS to the tunnel port.
--tls-ca PATHnoneExtra 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
bash
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
IP allowlists and your own server. On the hosted servers, viaductd sits behind Caddy, which passes it the real visitor address, so --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.