lpm tunnel
Share a local HTTP service through HTTPS, manage stable domains, and capture or replay requests.
lpm tunnel exposes a local HTTP service through a public HTTPS URL.
Use it for webhook callbacks, remote-device tests, and temporary previews.
lpm tunnel [OPTIONS] [ACTION] [DOMAIN] [ARGS]...ACTION defaults to start. A port number starts a tunnel to that port.
DOMAIN selects a claimed hostname for startup or domain management. For replay, this position accepts a capture number.
Quickstart
Start your HTTP service on port 3000. In another terminal, run:
lpm login
lpm tunnel 3000The command prints a public URL and forwards requests to http://127.0.0.1:3000/.
Keep the command open while you use the URL. Press q or Ctrl+C to stop it.
Tunnels need a Pro plan or one of your organization's claimed domains. See Plans and limits.
Choose an action
| Goal | Command |
|---|---|
| Tunnel the only active dev endpoint | lpm tunnel |
| Tunnel an explicit local port | lpm tunnel 3000 |
| Select a claimed domain | lpm tunnel 3000 preview.lpm.fyi |
| Use a claim with the active dev endpoint | lpm tunnel start preview.lpm.fyi |
| Claim a stable domain | lpm tunnel claim <DOMAIN> |
| Release a claim | lpm tunnel unclaim <DOMAIN> |
| List personal claims | lpm tunnel list |
| List enabled base domains | lpm tunnel domains |
| Inspect captured requests | lpm tunnel inspect |
| Replay a capture | lpm tunnel replay <NUMBER> --port 3000 |
| Browse or clear history | lpm tunnel log |
Aliases: release for unclaim, ls for list, and logs for log.
Project configuration
For a repeatable workflow, add a claimed domain to lpm.json and use lpm dev.
First, claim the hostname with your Pro plan. For a domain of an organization, an owner or administrator adds --org <slug>:
lpm tunnel claim preview.lpm.fyi{
"$schema": "https://cli.lpm.dev/schemas/lpm.json",
"tunnel": {
"domain": "preview.lpm.fyi"
}
}lpm devThe domain field enables the tunnel automatically. The tunnel value is an object and supports only domain.
Standalone lpm tunnel uses its command arguments and active dev endpoints. It does not read tunnel.domain from lpm.json.
Without services, lpm dev runs the dev script from package.json.
It waits for the final HTTP endpoint before it opens the tunnel.
Override or disable the project tunnel
lpm dev --domain another-preview.lpm.fyi
lpm dev --no-tunnel--domain selects another claim and enables tunneling for that run. --no-tunnel disables the configured tunnel for one run.
The --domain flag belongs to lpm dev. Standalone lpm tunnel takes the domain as a positional argument.
For an assigned URL, omit tunnel.domain and run:
lpm dev --tunnelA Pro account uses its account hostname, <username>.lpm.fyi. The lpm.dev username must be a valid hostname label: lowercase letters, digits, and hyphens, at most 63 characters. If it is not, the tunnel stops with domain_required. Set a username, or name a domain that you claimed. An organization member without Pro must name one of the claimed domains of the organization.
Select a service in a multi-service project
A project with one service uses that service. With multiple services, mark exactly one as primary:
{
"tunnel": { "domain": "preview.lpm.fyi" },
"services": {
"web": { "command": "vite", "port": 5173, "primary": true },
"api": { "command": "node api.js", "port": 4000 }
}
}lpm devThe public URL routes to the final endpoint of web. The api service remains local.
See the lpm.json tunnel reference.
Examples
lpm tunnel 4000 --session checkout-test
lpm tunnel 4000 --no-inspect
lpm tunnel 4000 --inspect-port 4500
lpm tunnel claim staging.lpm.fyi --org acme
lpm tunnel list --org acme --json
lpm tunnel inspect --filter stripe --status 4xx
lpm tunnel inspect --filter stripe --status 4xx --detail 1
lpm tunnel replay --last --port 4000 --json
lpm tunnel log --clearSelect the local service
An explicit port targets plain HTTP at 127.0.0.1. Valid ports range from 1 through 65535.
The public URL still uses HTTPS.
Without a port, LPM CLI selects the only active lpm dev endpoint across local sessions. It preserves that endpoint's address, port, and base path. If zero or multiple endpoints exist, the command asks you to supply a port.
# Terminal 1
lpm dev
# Terminal 2
lpm tunnelThe tunnel forwards HTTP requests and WebSocket connections, including streaming responses and HMR. The local child must use plain HTTP. For trusted local browser TLS, use lpm dev --https.
Authentication
Remote startup and domain management require a saved session from lpm login.
They do not accept --token, LPM_TOKEN, or standalone CI tokens for the hosted service.
Local inspect, replay, and log actions work without a login.
LPM CLI renews the session credential before it expires. Renewal preserves the public URL and active connections. Older relays refresh through reconnection, which can close active requests and WebSockets.
Revoking a login session also closes its active tunnels after LPM.dev Registry processes the revocation. Run lpm login again before restarting the tunnel.
Stable domains
Stable claims require a personal Pro plan or an eligible paid organization. List the enabled bases before you choose a hostname:
lpm tunnel domains
lpm tunnel claim preview.lpm.fyi
lpm tunnel list
lpm tunnel 3000 preview.lpm.fyiCurrent bases are lpm.fyi and lpm.llc.
The label must contain 3–32 lowercase letters, numbers, or hyphens. It must start and end with a letter or number.
Two hyphens in positions 3 and 4 are invalid. Account hostnames on lpm.fyi are reserved.
The labels relay and invalidate are reserved on every base.
Organization domains
Owners and administrators can claim and release organization domains. Current members can list and use those domains.
lpm tunnel claim staging.lpm.fyi --org acme
lpm tunnel list --org acme
lpm tunnel 3000 staging.lpm.fyi
lpm tunnel unclaim staging.lpm.fyi --org acmeThe claimed domain selects the billed organization. The member's personal plan does not control its allowance.
For release, --org requires the claim to belong to that organization.
Release takes effect without a confirmation prompt:
lpm tunnel unclaim preview.lpm.fyiIf release succeeds but immediate relay enforcement fails, the error states that the domain was released.
Recipes
Receive webhooks on localhost
Run the service and tunnel in separate terminals:
# Terminal 1
lpm run dev
# Terminal 2
lpm tunnel 3000 --session stripe-testAdd your webhook path to the printed public URL, such as https://<DOMAIN>/webhooks/stripe.
LPM CLI saves the request and local response for inspection and replay.
Replay a failed webhook
lpm tunnel inspect --last 10
lpm tunnel inspect --detail 3
lpm tunnel replay 3 --port 3000Capture numbers start at 1, newest first. Filters change the numbering of an inspection list.
To inspect a filtered result, repeat the same filters with --detail.
Replay numbers refer to the unfiltered history.
Replay sends the saved method, path, application headers, and body to the local target.
Use exactly one capture number or --last. With one active dev endpoint, the replay port is optional.
lpm tunnel replay --lastAcknowledge requests while the service is unavailable
lpm tunnel 3000 --auto-ackIf local forwarding fails, LPM CLI saves the request before it returns 200 OK. The tunnel process must remain open.
Storage failure, capture saturation, persistence timeout, or forwarding overload returns 503 with Retry-After: 1.
CAUTION: A forwarding failure leaves the processing outcome unknown. Before replay, check for existing side effects to avoid duplicate work.
Protect a preview URL
Tunnels support an access token:
lpm tunnel 3000 preview.lpm.fyi --tunnel-authInteractive startup shows the X-Tunnel-Auth header. Non-interactive human output hides the token. JSON startup includes it.
The token stays the same across reconnects within one CLI run.
curl -H 'X-Tunnel-Auth: <TOKEN>' https://preview.lpm.fyi/For browser access, https://preview.lpm.fyi/?__tunnel_auth=<TOKEN> sets an access cookie and redirects to a URL without the token.
Treat the token, JSON output, and initial browser URL as secrets.
Control the browser inspector
lpm tunnel 3000 --inspect-port 4500
lpm tunnel 3000 --no-inspect
lpm tunnel inspect --uiThe default inspector binds to an available loopback port. An explicit busy port stops startup.
A default-port inspector failure does not stop the tunnel. --no-inspect disables the browser UI while capture continues.
Historical inspection opens the project's database. Use --inspect-port with inspect --ui to select its port.
Press Ctrl+C to stop it. During a tunnel, press o to open its inspector.
Capture history
History lives in .lpm/inspector.db in the project. Starting a tunnel and the history commands act on the nearest directory at or above the current one that has a package.json or an lpm.json, stopping before your home directory. LPM CLI prints that directory when it is not the current one, so you can run them from any subdirectory. Without such a directory, they use the current directory. They refuse a directory whose .lpm would be the LPM home. claim, unclaim, list, and domains don't use a project.
Captured data holds raw request and response headers and bodies, including cookies, authorization headers, and signature headers. .lpm/.gitignore keeps the database out of Git. See Keep captures out of git.
lpm tunnel inspect # newest 20 captures
lpm tunnel log # newest 50 captures
lpm tunnel log --last 10 --filter github
lpm tunnel log --status 5xx
lpm tunnel log --clearStatus filters accept 2xx, 3xx, 4xx, 5xx, error, err, or an HTTP status from 100 through 599.
Provider filters are case-insensitive.
log --clear deletes captured requests and completed sessions. Active sessions remain available for new captures.
LPM CLI overwrites the deleted captures in the database. It also empties the write-ahead log, which still holds them. If another LPM CLI run reads the database at that time, the log keeps the captures until the last run that uses the database stops. SQLite then removes the log. If root ran the tunnel in the project of another user, SQLite empties the log instead.
It cannot combine with filters or a count limit. inspect --ui cannot combine with CLI capture filters or JSON output.
Keep captures private
On Unix, LPM CLI keeps captures only in a project whose path no other user can change. Another user can try to rename .lpm, or a directory above it, while the capture files open. If that is possible, the user can make LPM CLI write captures to a different location, or read them. LPM CLI examines each directory from / to the project, and follows links:
- The directory must be yours or root's.
- Only you, or a group that is yours alone, can write to it. Such a group is your primary group, has your name, and has no other members.
- Others can write to a sticky directory, for example
/tmp. Such a directory is safe if the entry in it on the path is yours or root's. - On Linux, LPM CLI refuses a directory whose access control list can let others write to it. On macOS, LPM CLI refuses a directory whose access control list lets others add, remove, or rename its entries.
If a directory does not meet these rules, LPM CLI names the directory and stops. This also applies to history commands. Keep the project under directories that only you can change.
On Unix, only the owner can read and write the capture files. On Windows, they inherit the ACLs of the project. lpm tunnel and lpm dev --tunnel record captures, and set this access:
- On Unix,
.lpmmust be your own. LPM CLI makes it writable only by you. On macOS, LPM CLI also removes its extended ACL entries. These entries can give access that its mode does not show, and pass that access to the files created in it. - On Unix, if other users can read a capture database, LPM CLI replaces it with a copy that only the owner can read. A handle that another user opened before then cannot read later captures. Earlier LPM CLI releases or a checkout can leave a database readable. Other users can read it if its mode lets its group or anyone read it. On macOS, they can also read it if it has an extended ACL.
- In some locations, file modes do not keep other users out, and a copy does not change that. One is a filesystem without Unix modes, for example FAT, exFAT, or a Windows drive mounted in WSL. Such a filesystem shows every file as open to other users. The other is a macOS volume that ignores ownership, as external drives and disk images do by default. On such a volume, every user owns every file, whatever its mode. In these locations, the tunnel keeps the database, and its capture warning says that the database is open to other users.
Before LPM CLI makes the owner-only copy:
- LPM CLI makes sure that the database is not damaged, its full-text index included. It refuses a damaged database.
- If another process has the database open, LPM CLI stops and asks you to close that process and try again. That process can be
lpm tunnel inspect --ui, or anotherlpm tunnelorlpm devrun in the project. - Before LPM CLI opens the database, it makes sure that there is enough free disk space. The copy needs about the size of the database, plus its
-walfile, plus 64 KiB. A large-walfile needs a little more. The-walfile is the write-ahead log of the database. LPM CLI folds it into the database before it makes the copy. The 64 KiB is for the shared-memory file of SQLite. - APFS refuses writes while it still reports about 2% of the volume as free, up to about 32 MiB. LPM CLI counts that space, and a little more, as used.
- If there is not enough room, or if the disk fills during the copy, LPM CLI says how much room it needs and stops. The captures stay in the database and its
-walfile. The database stays open to other users until a retry replaces it. To move the database aside instead, see Recover the capture database.
lpm tunnel inspect, log, replay, and inspect --ui read the captures and do not change the access of any file. As a result, they work while a tunnel records to the database. If other users can read the database, these commands show a warning. The next lpm tunnel or lpm dev --tunnel run in the project makes the database owner-only, where file modes keep other users out. On a macOS volume that ignores ownership, no run can do this. The warning then says to keep the project on a volume that keeps ownership.
Run as root
On 64-bit Linux, if another user owns the project, root opens its capture files as that user. This occurs, for example, in a container over a mounted checkout. As a result, that user cannot make root write where the user cannot write, also if the user replaces .lpm while a tunnel starts.
- The files that root creates belong to that user, in the primary group of the user. A user that the system does not know, for example the host user in a container, gets the group of the project. If the group of the project is root's, the user gets group 65534.
- If
.lpmis root's and no other user can change it, root first gives it to that user. Root also gives that user the capture files that root made there. This occurs, for example, afterlpm installran as root. Root gives only regular files that have no other hard links. - If root still owns a capture file after that, for example one in a
.lpmof that user, the tunnel stops. The error names the file. Give the file to that user withchown, or remove it. - When the last run stops, SQLite empties the write-ahead log and keeps the empty file.
- If the process cannot act as that user, LPM CLI stops and says so. This occurs without the
CAP_SETUIDandCAP_SETGIDcapabilities, or in a user namespace that does not map the user.
On macOS and other systems, root stops for any project that another user owns. Run the tunnel as the owner of the project, without sudo. In a project that root owns, the runs of root are the same as the runs of any user.
Keep captures out of git
Every LPM CLI command that writes to .lpm keeps a .lpm/.gitignore that holds *, so git ignores everything in .lpm. Before LPM CLI opens the capture database, it makes sure of this again. Git then ignores the database, its -wal and -shm files, and the inspector.db-lock file of LPM CLI, wherever the project is in a repository. If a later rule cancels *, for example a negation such as !*.db, LPM CLI replaces the file with one that holds *. LPM CLI does not change .lpm/.gitignore in these cases:
- Git does not read it as a file, because it is a link or not a regular file.
- It is larger than 16 MiB.
- LPM CLI cannot write it, because it is read-only or, on Unix, another user's.
At each start, the capture warning asks git how it treats the capture files. It asks the repository of the project which capture files it tracks. At the same time, it asks git inside .lpm which files a repository of .lpm itself tracks, for example a submodule. It also asks which files no rule ignores. A repository around the project's repository is asked too, as its index can still list capture files from before the project's repository was made.
Git itself says whether the project is in a repository, and whether .lpm has a repository of its own. If the project is not in a repository, and git refuses a repository inside .lpm, that repository belongs to .lpm.
LPM CLI reads the .git entries of the project and the directories above it only if git cannot run or answer, or stops its search. Git stops at a filesystem boundary. It also stops at a .git file that it cannot read, that is larger than 1 MiB, or that does not point at a repository. LPM CLI reads .lpm/.git only if git cannot run or answer inside .lpm. LPM CLI follows the rules of git 2.53.
A .git directory, or a link to one, belongs to a repository if two things are true. Its HEAD names a branch or a commit, and it has objects and refs directories. The .git directory of a worktree names the directory that has them in a commondir file. LPM CLI reads a .git file, or a link to one, only if it is 1 MiB or less. The file belongs to a repository if its content after gitdir: is the path of such a directory. The final line breaks of the content do not count.
Outside any repository, the warning says nothing about git if neither query answers. Inside a repository, a query is unanswered if it cannot run or takes more than two seconds. A query that stops at a filesystem boundary or a broken .git file is also unanswered. The query of the project is also unanswered if git refuses it, as git refuses a repository that another user owns. A query of git inside .lpm that finds no repository is unanswered too. The warning then says which repository did not answer. It asks you to make sure that git ignores the capture files and does not track them. If the other query answers, the warning also reports what it found, for example tracked files.
- If git ignores the capture files and no repository tracks them, the warning says so.
- If git does not ignore them, the warning names them. End
.lpm/.gitignorewith*, so git ignores all of.lpm. Git does not read a linked.lpm/.gitignore, or one that is not a file. Replace such an entry with a file that holds*. - Git refuses to work inside
.lpmif.lpmlooks like a bare repository, or holds a repository that another user owns. In that case, the warning reports what the repository of the project says, if the project is in one. It asks you to make sure that git ignores the capture files and does not track them.
The warning names files whose names hold only letters, digits, ., _, -, and /, and counts the others. Git can track a capture file under any spelling the file system takes for its name or for a directory above it: inſpector.db for inspector.db on APFS, the name with a zero-width mark in it on HFS+ or in a Linux casefold directory, ../DIR/.lpm/inspector.db when the repository spells the project's directory DIR, or on Windows an NTFS short name such as LPM~1/inspector.db or .lpm./inspector.db, as Win32 drops the dots and spaces a name ends with. The warning counts those too. Its command names each tracked file when there are at most five and every shell reads each name the same way. Otherwise the command matches the capture names, and no other file, in each directory that holds them, as in git rm --cached --ignore-unmatch -- ":(glob).lpm/[iI][nN][sS][pP][eE][cC][tT][oO][rR].[dD][bB]*" …, and names each file that no pattern matches: one tracked by an NTFS short name, such as .lpm/INSPEC~1.DB, or by a name with a zero-width mark. If only git in .lpm answered, the command names the files from the top of the repository, as in git rm --cached -- ":(top,literal)packages/app/.lpm/inspector.db".
Remove committed captures
If a repository tracks capture files, the warning names them. It gives the command that stops the tracking for each repository: git rm --cached -- .lpm/inspector.db for the repository of the project, git -C .lpm rm --cached -- inspector.db for a repository of .lpm itself, or git -C <directory> rm --cached -- … for a repository around the project's. An ignore rule does not prevent the commit of a tracked file.
Captures that you already committed stay in the history of the repository, and in every clone of it. Rotate the credentials that they hold: webhook signing secrets, authorization tokens, and session cookies. Rotate them first, because a rewrite of the history does not change clones and forks that others already have.
For a public repository, also remove the captures from its history.
CAUTION: Back up the repository before you rewrite its history. A rewrite changes the ID of every commit after the first commit that it changes. Everyone with a clone must clone again, or reset to the new history. Open pull requests can break, and you must then create them again.
Recover the capture database
LPM CLI refuses some capture files and stops. A cloned repository can supply any of these:
- A linked
.lpmdirectory, or on Unix, a.lpmdirectory that another user owns - A linked capture file, a capture file with other hard links, or on Unix, a capture file that another user owns
- A database that holds anything that LPM CLI never creates, for example triggers, views, check constraints, or extra indexes
- A database that SQLite reports as damaged
If LPM CLI refuses the .lpm directory, move the directory aside and try again. If it refuses a capture file or a damaged database, move the database aside with its -wal, -shm, and -journal files. Then try again. LPM CLI starts a new database.
The moved files hold captures, so keep them private. Move them into a directory that only you can read, outside every repository. Keep the suffix of each file after the name of the database, so that SQLite still finds the files together:
mkdir -p -m 700 ~/lpm-old-captures/my-project
for suffix in "" -wal -shm -journal; do
if [ -e ".lpm/inspector.db$suffix" ]; then mv ".lpm/inspector.db$suffix" ~/lpm-old-captures/my-project/; fi
doneIf LPM CLI did not warn that other users can read the database, you can keep the files in .lpm instead, where .lpm/.gitignore keeps them out of git. Rename them together and keep each suffix, for example inspector.db.old and inspector.db.old-wal. Delete the files when you no longer need their captures.
The -wal file can hold captures that the database does not have yet. If LPM CLI finds the -wal, -shm, and -journal files without a database, it removes them, unless it refuses them as above.
If another LPM CLI run opens the capture files, LPM CLI waits up to 10 seconds, then stops. An error when LPM CLI opens, reads, or writes the database is an I/O error, io in --json output.
Plans and limits
Tunnels need a Pro plan, or an organization plan through one of the claimed domains of the organization. If you have neither, startup stops with plan_required and shows a link to lpm.dev pricing.
| Tunnel account | Default domain | Concurrent tunnels | Stable claims | Access token |
|---|---|---|---|---|
| Pro personal | Account hostname | 3 | 3 | Yes |
| Org | None (name a claimed domain) | 3 | 3 | Yes |
Organization concurrency and claim limits apply across the organization, not per seat. Startup shows current limits and request usage. See Request usage and overage for account allowances.
JSON and recovery
lpm tunnel list --json
lpm tunnel inspect --last 10 --json
lpm tunnel replay --last --port 3000 --json
lpm tunnel 3000 --no-inspect --jsonLocal history commands work in non-interactive scripts. A remote tunnel still needs a saved login session and remains open until stopped. Startup emits a JSON object after each successful connection. Temporary failures emit retry objects. Read this output as a sequence of JSON objects, not one document.
Temporary authentication-service and domain-lookup outages retry. Relay restarts and abnormal transport closures also retry.
Invalid credentials, denied ownership, and an account whose plan does not include tunnels stop the command with an error.
If the plan of an account no longer includes tunnels, the relay closes the open tunnels of the account, usually within a minute. Then the command stops.
In JSON output, these refusals have the error_code tunnel_access. The error.relay_code field gives the code from the relay, for example plan_required or billing_inactive.
A normal relay replacement closes the older tunnel successfully.
The retry budget is ten attempts. Delays start at two seconds and increase to thirty seconds, plus jitter. A healthy connection of at least sixty seconds resets the budget.
A claimed domain stays the same across reconnections. An account hostname follows your current username. Use the tunnel_url from the latest connection.
Applications must reconnect their SSE and WebSocket streams.
An established tunnel retries temporary capacity conflicts during recovery. A new tunnel stops when the account is already at its limit.
Relay certificate changes
LPM CLI verifies the certificate chain, hostname, and saved public-key pin.
For relay.lpm.fyi, each connection attempt also requires a signed list of approved keys, even when the saved pin matches.
An approved replacement connects automatically and updates the saved pin. Your tunnel command stays the same.
Unknown keys, invalid certificates, and invalid or expired approval lists stop the command immediately.
If the error asks for an update, run lpm self-update, then retry your tunnel command.
If the error continues, contact the relay operator. Pin-file deletion is not part of recovery.
If the approval service is unavailable, retry the command when the network is available.
Use another relay
LPM_TUNNEL_RELAY overrides tunnel.relay-url in $LPM_HOME/config.toml. The default relay is wss://relay.lpm.fyi/connect.
Remote relays require wss://. Plain ws:// is accepted only for loopback hosts.
URLs with user credentials or malformed authorities are rejected and fall back to the default.
Use only a trusted relay because it receives the tunnel credential.
Flags
| Flag | Applies to | Effect |
|---|---|---|
--org <SLUG> | claim, unclaim, list | Select or constrain the organization domain scope. |
--tunnel-auth | Start | Require the generated access token. |
--auto-ack | Start | Return 200 OK after durable capture when local forwarding fails. Return 503 if capture fails or forwarding is overloaded. |
--session <NAME> | Start | Name the capture session. |
--no-inspect | Start | Disable the browser inspector. Capture continues. |
--inspect-port <PORT> | Start, inspect --ui | Bind the inspector to an exact port. Conflicts with --no-inspect. |
--ui | inspect | Open the browser inspector. |
--last <N>, -n <N> | inspect, log | Show the newest N matching captures. |
--last, -n | replay | Replay the newest capture. |
--detail <N>, -d <N> | inspect | Show the numbered capture, using any supplied filters. Conflicts with --last. |
--filter <PROVIDER> | inspect, log | Select a detected provider. |
--status <STATUS> | inspect, log | Select a status or status class. |
--clear | log | Delete capture history. Conflicts with filters and limits. |
--port <PORT>, -p <PORT> | replay | Select the local replay port. |
--json | All except inspect --ui | Write machine-readable output. This is a global flag. |
Flags for another action cause an error instead of silently doing nothing.
See also
- lpm dev — start a tunnel with the project
- Tunneling — domains, allowances, and transport behavior
- lpm cert — trusted local HTTPS
- Environment variables — relay and home-directory overrides