Expose a local service through a tunnel
The commands and configuration on this page were audited against v5.0.0-next.14.
The execution notes below come from the newer recipe source revision; the examples
were not rerun with the pinned binary.
Use this when something outside your machine must reach a local service: a
webhook sender, a phone, or a teammate. The tunnel runs as a Hack lifecycle
process, so hack up starts it and hack down stops it.
The complete project is in
examples/recipes/tunnel. It uses
cloudflared quick tunnels, which need no account. Any tunnel CLI that runs in
the foreground works the same way.
How it works
Steps
-
Publish the service on the loopback address only, so the tunnel on your machine can reach it and nothing else on your network can.
name: tunnel services: web: image: oven/bun:1.4.4-slim working_dir: /app command: ["bun", "src/server.ts"] volumes: - ..:/app:ro # The tunnel runs on the host, so the app is published on loopback only. ports: - "127.0.0.1:4310:3000" -
Add the tunnel as a lifecycle process. It must stay in the foreground; Hack runs it in its own tmux session and stops it on
hack down.{ "$schema": "https://schemas.hack.local/hack.config.schema.json", "name": "tunnel", "dev_host": "tunnel.hack.local", "lifecycle": { "processes": [ { "name": "tunnel", "command": "cloudflared tunnel --no-autoupdate --url http://127.0.0.1:4310" } ] } } -
Start the project and read the public URL from the tunnel's log:
hack up -d hack logs tunnel --no-follow | grep -o 'https://[a-z0-9-]*\.trycloudflare\.com' -
Send a test request through the tunnel:
curl -X POST -d '{"event":"test"}' https://<your-tunnel>.trycloudflare.com/webhooks hack logs web --no-follow --tail 1 # webhook received: {"event":"test"} -
Stop the project.
hack downstops the containers and the tunnel process. The public URL stops working.hack down
Lifecycle processes need tmux (or zellij). See Lifecycle.
OAuth callbacks and stable URLs
A quick tunnel gets a new random hostname on every start. That is fine for webhooks you register by hand while testing. OAuth providers need a callback URL that you register in advance, so they need a hostname that stays the same:
- A named tunnel. Create it once in your tunnel provider's account and run
it with a token instead of
--url. Store the token with<secret-source> | hack env add TUNNEL_TOKEN --service host --secret --local --value-stdin. Thehostscope delivers it to host commands and lifecycle processes while keeping it out of thewebcontainer. Use this pattern in projects without a Compose service namedhost: that name otherwise selects the container's service scope. Forcloudflared, the process command is then justcloudflared tunnel run: it readsTUNNEL_TOKENfrom its environment (cloudflared tunnel run --helplists it). Do not write--token "$TUNNEL_TOKEN"; that puts the token in the process's arguments, where other tools on your machine can read it. We did not run a named tunnel, because it needs an account. - A local OAuth alias. When the provider only needs a callback that your
browser can reach (not a public one), Hack's
.hack.gyalias may be enough. See--oauthand--oauth-tldunderhack initin the Command reference.
Point your app's public URL setting (for example PUBLIC_URL) at the stable
hostname, in the overlay you use for tunnel work. See
Environments.
Notes
- Tunnel to the published port, not to the Hack route
(
https://tunnel.hack.local). A tunnel to the route needs the global proxy, its local CA and the rightHostheader. We did not test that path. - Anyone with the URL reaches your service. Do not tunnel a service that has no sign-in, or that holds data you would not publish.
- If you start the same tunnel by hand, Hack starts a second copy. For fixed
local ports, see
singletonin Lifecycle.
Related: Lifecycle, Common pitfalls.
Source: Recipe corrections (next.14 compatible) · docs/recipes/tunnels.md.