Getting Started Guide¶
lfr-tunnel is a client-server utility. The client CLI (lfr-tunnel) runs on your local machine and establishes a secure tunnel to the gateway server (lfr-tunneld) running on a public VPS.
Info
The client CLI binary is of no use on its own. It cannot establish a tunnel without connecting to a running gateway server, and it requires a valid Personal Access Token (PAT) to authenticate.
This guide walks you through installing the client, registering for access, claiming your token, and running your first tunnel.
Overview of the Flow¶
[ Developer Laptop ] [ Gateway Server ]
(lfr-tunnel CLI) (lfr-tunneld)
│ │
│ 1. Submit Registration ───────────────────────────────────►
│ (Provides email & requested subdomain) │
│ │
│ 2. Admin Approves │
│ (Validates request) │
│ │
│ 3. Claim PAT Token ◄──────────────────────────────────────┘
│ (Via approval email link)
│
│ 4. Store Token
│ (Saved in ~/.lfr-tunnel/token)
│
│ 5. Connect Tunnel ────────────────────────────────────────► Exposes local ports
Step 1: Install the Client¶
Before registering, install the lfr-tunnel client for your operating system.
Recommended: Package Managers¶
Using a package manager ensures you get automated integrity validation (SHA-256 checks) and clean path management.
macOS / Linux (Homebrew)¶
brew tap peterrichards-lr/tap
brew trust peterrichards-lr/tap
brew install lfr-tunnel
Windows (Scoop)¶
scoop bucket add peterrichards-lr https://github.com/peterrichards-lr/scoop-bucket
scoop install lfr-tunnel
Direct Script Fallback¶
If package managers are not available on your system, use the direct installation scripts.
macOS / Linux¶
curl -sSfL https://raw.githubusercontent.com/peterrichards-lr/lfr-tunnel/master/pkg/server/static/install.sh | sh
Windows (PowerShell)¶
iwr https://raw.githubusercontent.com/peterrichards-lr/lfr-tunnel/master/pkg/server/static/install.ps1 | iex
Verify your installation:
lfr-tunnel -version
Step 2: Register for Access¶
Access to the gateway server is authenticated via a Personal Access Token (PAT) associated with your user account.
1. Submit a Registration Request¶
To request access, send a registration request to the gateway server.
-
For Liferay Sales Engineering Team (connecting to the hosted SE gateway): Submit a request using the hosted server:
(Replacecurl -s -X POST \ -H "Content-Type: application/json" \ -d '{"email": "your.name@liferay.com", "requested_subdomain": "your-name-se"}' \ https://tunnel.lfr-demo.se/api/register-requestyour.name@liferay.comwith your official email, andyour-name-sewith your desired default subdomain). -
For Self-Hosted Gateways: Replace
https://tunnel.lfr-demo.sewith your own gateway's URL:curl -s -X POST \ -H "Content-Type: application/json" \ -d '{"email": "admin@example.com", "requested_subdomain": "my-subdomain"}' \ https://tunnel.yourdomain.com/api/register-request
You will receive a terminal output confirming your request has been successfully submitted and is pending admin approval.
2. Verify Your Email & Wait for Approval¶
- Check your inbox for a Verification Email from the gateway. Click the link inside to verify that you own the email address.
- Once verified, the gateway administrator receives a notification.
- Once the administrator approves your request, you will receive an Approval Email containing a link to claim your token.
3. Claim Your Token¶
Click the link in your approval email, or run the following curl command using the claim token found in the email:
curl -s "https://tunnel.lfr-demo.se/api/claim?token=<claim-token-from-email>"
The gateway will respond with your Personal Access Token (PAT) (e.g., lfr_pat_abc123...).
Warning
This token is shown only once. Copy it immediately and store it securely.
Step 3: Authenticate and Store Your Token¶
To make using lfr-tunnel seamless, the client CLI looks for a stored PAT in your home directory (~/.lfr-tunnel/token or %USERPROFILE%\.lfr-tunnel\token). Once saved, the client will automatically load it on every run without needing any -token flags.
There are two ways to generate, claim, and save your token:
Option A: Automatic Browser Login (Highly Recommended)¶
The client includes an interactive Magic Handoff flow that automatically completes token generation and saves it to your configuration directory with zero manual copying:
- In your terminal, run the login command:
lfr-tunnel login - Your default web browser will open to the gateway's User Portal.
- Authenticate on the portal (using your approved email and magic link).
- Upon logging in, the portal will securely hand off a newly generated token back to your local client terminal session.
- The CLI saves the token automatically:
✅ Successfully authenticated! Your token has been saved securely to ~/.lfr-tunnel/token
Option B: Manual Clipboard Configuration¶
If you claimed your token manually via curl or generated one in the User Portal web interface, you can save it to the default path yourself:
macOS / Linux¶
mkdir -p ~/.lfr-tunnel
echo "lfr_pat_your-token-here" > ~/.lfr-tunnel/token
chmod 600 ~/.lfr-tunnel/token
Windows (PowerShell)¶
New-Item -ItemType Directory -Force -Path "$Home\.lfr-tunnel"
Set-Content -Path "$Home\.lfr-tunnel\token" -Value "lfr_pat_your-token-here"
Danger
Never commit your PAT to source control. Storing the token in ~/.lfr-tunnel/token ensures it is kept completely outside your development workspace.
Storing it somewhere else¶
~/.lfr-tunnel/token is found automatically, but it is the only path that is. If you keep
credentials elsewhere — a password-manager mount, a per-project directory, ~/.config — name
that file with token_file: in ~/.lfr-tunnel/config.yaml instead:
token_file: "~/.config/lfr-tunnel/pat"
The point is the same as Option B's: the token stays out of the config file, which is the file that gets pasted into support threads and copied between machines. See Keeping the token out of this file.
Option C: Restricted Secrets File (Advanced & Secure)¶
This matches the security practices taken in LDM. Instead of storing the token raw in ~/.lfr-tunnel/token, you store it in a restricted variables file which you source in your shell profile.
On macOS / Linux (Bash or Zsh)¶
- Create the restricted folder and secrets file:
mkdir -p ~/.config/lfr touch ~/.config/lfr/secrets chmod 600 ~/.config/lfr/secrets - Add your token variable to the file:
echo 'export LFT_CLIENT_TOKEN="your_actual_token_here"' >> ~/.config/lfr/secrets - Source the file in your profile by adding this to the bottom of your
~/.zshrcor~/.bashrc:[ -f ~/.config/lfr/secrets ] && source ~/.config/lfr/secrets
On Windows (PowerShell)¶
- Run these commands in PowerShell to create the secrets folder/file and restrict permissions to only your explicit user account:
New-Item -ItemType Directory -Path "$HOME\.config\lfr" -Force $SecretFile = New-Item -ItemType File -Path "$HOME\.config\lfr\secrets.ps1" -Force # Restrict permissions so ONLY you can access it $Acl = Get-Acl $SecretFile.FullName $Acl.SetAccessRuleProtection($true, $false) $User = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $Rule = New-Object System.Security.AccessControl.FileSystemAccessRule($User, "FullControl", "Allow") $Acl.AddAccessRule($Rule) Set-Acl $SecretFile.FullName $Acl - Add the token to the file:
Set-Content -Path "$HOME\.config\lfr\secrets.ps1" -Value '$env:LFT_CLIENT_TOKEN="your_actual_token_here"' - Load it automatically on shell startup. Open your PowerShell profile (
notepad $PROFILE) and add:if (Test-Path "$HOME\.config\lfr\secrets.ps1") { . "$HOME\.config\lfr\secrets.ps1" }
The client CLI (lfr-tunnel) will automatically load your token from these files if it is not configured via other mechanisms.
Step 4: Run Your First Tunnel¶
Once your token is saved, you can run the client. If you do not tell it which ports to
publish, it works them out — by scanning the Liferay Workspace you are standing in, or, if
you are not in one, by looking for a running Liferay or LDM instance. Port 8080 is what you
get when neither turns anything up.
Naming ports yourself — -ports, ports: in your config file, or LFT_CLIENT_PORTS —
switches all of that off and publishes exactly what you asked for.
Zero-Config Workspace Mode (LDM/Workspaces)¶
Navigate to your Liferay Workspace root directory and run:
lfr-tunnel -subdomain your-name-se
The client will: 1. Scan for active client extensions and detect their development ports automatically. 2. Authenticate with the stored PAT. 3. Print the live public HTTPS URLs where your local server and assets are now accessible.
The scan walks the current directory for client-extension.yaml files and takes the port
from each. Port 8080 is always the first thing published, on your plain subdomain; each
client extension gets its own subdomain suffixed with the extension's key, so an extension
named my-remote-app on port 4001 is served at your-name-se-my-remote-app.
This did not work until #1710. The client shipped with
portspre-set to[8080], which it could not tell apart from your having asked for8080, so the scan above never ran and only8080was ever published. If you had worked around it with an explicitports:list, that list still wins — delete it to get the scan.
Port-Specific Standalone Mode (Tomcat/Docker)¶
If you are running a standalone Tomcat bundle on port 8080 without a Liferay Workspace:
lfr-tunnel -subdomain your-name-se -ports 8080
Outside a workspace, leaving -ports off instead asks the client to find the instance
itself: it reads docker ps for a container whose name or image mentions liferay, dxp or
ldm and takes the ports it publishes, and failing that probes 8080, 13000 and 3000 on
127.0.0.1. Naming the port explicitly, as above, is still the more predictable option when
you already know it.
That same pass also decides where to forward, not only what to publish: the host it found
the instance on becomes target_host, unless you supplied one with -target-host,
LFT_TARGET_HOST or target_host: — all three of which beat it. A discovered localhost is
normalised to 127.0.0.1 first, because localhost often resolves to ::1 ahead of the IPv4
address, and where ::1 is dropped rather than refused that costs you a hang mid-demo instead
of a fast fallback. With nothing set and nothing discovered you still get 127.0.0.1. The full
order is in
Leaving target_host unset.
Choosing Which Gateway to Use¶
Most people need none of this: the client ships knowing its gateway, fetches the list of available ones, and picks whichever answers fastest.
If you do need to name one, the flag you choose changes the behaviour:
| How you supply it | Picks the closest | Fails over |
|---|---|---|
-bootstrap <url> |
✅ | ✅ |
server_url: in your client config file |
✅ | ✅ |
-pin <url> |
❌ pinned to that gateway | ❌ |
LFT_SERVER_URL / LFT_CLIENT_SERVER / LFT_SERVER |
❌ pinned | ❌ |
# Start from this gateway, but still pick the closest and fail over:
lfr-tunnel -bootstrap https://your-gateway.example.com -subdomain your-name-se
# Or persist it, with the same effect:
# server_url: "https://your-gateway.example.com"
-pin pins deliberately -- use it when you want one specific gateway and nothing else. But
if you pin a gateway on another continent you stay there however close an edge is, and your
tunnel drops for the whole window if that gateway is scheduled to stop.
To prefer a region while keeping failover, use -prefer-region <name>. To re-run the latency probe
after a gateway has come back, add -refresh-region once -- the election is otherwise cached for
24 hours.
Note
These three flags were renamed. -pin, -bootstrap and -prefer-region used to be
-server, -gateway and -region -- names that said where the value came from rather than
what it did to routing, which is how -server came to pin a US user to a European gateway for
the life of a tunnel.
| old | new |
|---|---|
-server <url> |
-pin <url> |
-gateway <url> |
-bootstrap <url> |
-region <name> |
-prefer-region <name> |
The old spellings still work. They set the same thing and print a deprecation warning, so existing scripts and aliases keep running -- but they will be removed, and giving both spellings of one setting with different values is an error rather than a silent winner.
Tip
server_url: above goes in the client config file, ~/.lfr-tunnel/config.yaml. It is worth
knowing that file exists: it holds every setting you would otherwise retype -- subdomain,
ports, target host, access controls -- and it is the only place a gateway can be named
without pinning. See the Client Configuration File reference
for every key, its default, and how it interacts with flags and environment variables.
Running in the Background & Start on Login¶
If you want the tunnel client to run silently or initialize automatically when you log into your machine, you can configure background execution and autostart configurations.
1. Headless Background Execution (CLI)¶
You can launch and manage the client in the background directly from your terminal using process control flags:
- Start in Background: Runs the tunnel as a detached daemon process:
lfr-tunnel -background -subdomain your-name-se - Check Status: Verifies if the daemon is active and prints the running PID and leased public URLs:
lfr-tunnel -status - Stop Daemon: Terminates the background process cleanly:
lfr-tunnel -stop - Status as JSON: The same information as
-status, machine-readable, for a script or a monitoring check rather than a human:lfr-tunnel -status-json
Other flags worth knowing¶
These are defined by the client and were, until now, documented nowhere -- found by rendering the flag parser rather than reading the source (#2081):
| Flag | Default | What it does |
|---|---|---|
-inspector-port <port> |
4040 |
Local port for the Inspector web UI. Change it when something else already holds 4040 -- the Inspector is how you read request bodies, so losing the port loses the feature. |
-no-tui |
off | Disables the interactive terminal dashboard and prints plain log lines instead. What you want when the client runs under a CI job, a wrapper script, or anything that is not a terminal. |
-status-json |
off | -status as JSON. |
-check-version |
off | Asks the gateway what client version it requires and prints the answer as JSON, without starting a tunnel. Use it to find out whether an upgrade is due before one is forced. |
-theme <name> |
unset | Local UI theme: light, dark, system or time. |
-log-dir <path> |
~/.lfr-tunnel/logs |
Where the persistent traffic and error logs are written. |
2. Autostart on Login¶
You can install startup items that launch the client automatically when your user logs in.
Headless CLI Client¶
To configure the headless background tunnel daemon to launch on login: * macOS / Linux / Windows: Run the subcommand:
lfr-tunnel install-service
- System Details:
- macOS: Creates a LaunchAgent plist at
~/Library/LaunchAgents/com.liferay.tunnel.plist. - Windows: Installs a hidden script at
~\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\lfr-tunnel.vbs. - Linux: Creates and registers a systemd user service at
~/.config/systemd/user/lfr-tunnel.service. You can monitor it using:systemctl --user status lfr-tunnel.service
System Tray GUI Client¶
Start the tray with:
lfr-tunnel -gui
The tray connects on startup. It brings the tunnel up with exactly the arguments its
Connect menu item would use -- so every flag you give the GUI applies to the tunnel it
starts, and lfr-tunnel -gui -prefer-region apac lands in apac without anyone clicking
anything. If a client is already running, the tray attaches to that one instead of starting a
second.
To start the tray as a control surface only, leaving the tunnel down until you ask for it:
lfr-tunnel -gui -no-autoconnect
This is worth knowing before you enable the autostart below: with it on, the default means a tunnel is up as soon as you log in.
To configure the System Tray / Menu Bar utility to launch on login:
* Tray Toggle: Simply open the system tray menu and click Launch on Login (displays a checkmark ✓ when enabled).
* CLI Command: Alternatively, register the autostart items using subcommands:
* Enable GUI autostart: lfr-tunnel install-gui-service
* Disable GUI autostart: lfr-tunnel uninstall-gui-service
- System Details:
- macOS: Registers
~/Library/LaunchAgents/com.liferay.tunnel.gui.plist. - Windows: Installs
~\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\lfr-tunnel-gui.vbs. - Linux: Creates a desktop autostart entry at
~/.config/autostart/lfr-tunnel-gui.desktop.
Client Lifecycle Hooks & Failover Automation¶
lfr-tunnel runs user-configured shell commands when the tunnel moves between gateways --
when a gateway announces it is stopping, and around the failover or failback that follows.
They exist so the things that care about the tunnel's public URL (a Liferay virtual host, a
webhook registration, a DNS record) can be updated without watching the logs.
Configuration (~/.lfr-tunnel/config.yaml)¶
Add script paths or commands under the hooks section. Anything you leave out is simply not
run. Every other key in this file is described in the Client Configuration
File reference:
hooks:
warning_received: "/usr/local/bin/on-gateway-warning.sh" # The gateway has announced it is stopping
stopping: "/usr/local/bin/on-gateway-stopping.sh" # Right before the old tunnel is torn down
stopped: "/usr/local/bin/on-gateway-stopped.sh" # The old tunnel has closed
starting: "/usr/local/bin/on-gateway-starting.sh" # Before the client re-registers elsewhere
started: "/usr/local/bin/on-gateway-started.sh" # A session is live again on the new gateway
Each value is a command line, not a file path with special handling -- it is passed to
/bin/sh -c (cmd.exe /c on Windows), so pipes, arguments and shell syntax all work.
When each hook fires¶
The four session hooks fire around a move, not around the process. A move is a failover after a fault, a failback to the primary region, or a planned migration ahead of an announced gateway stop.
| Event | Fires |
|---|---|
warning_received |
The connected gateway announces it is going down. Once per announcement, not once per heartbeat -- the warning repeats for the whole countdown. |
stopping |
The session is ending and the client is about to move, while the old tunnel is still standing. |
stopped |
The old tunnel has closed and its session has been cancelled. |
starting |
Before the client begins re-registering. The destination region is not chosen yet. |
started |
A session has been re-established and the new endpoint recorded. Fires for both a failback and a failover. |
In a planned migration the full sequence is
warning_received → stopping → stopped → starting → started. An unannounced failure
produces the same sequence without warning_received. If every candidate region is exhausted,
started does not fire -- there is no new session to report.
A client that has nowhere to fail over to -- pinned with -pin, or offered no region list --
re-registers with the gateway it is already on when its session ends, which is what carries it
across a gateway restart (#1946).
That fires stopped → starting → started, with no stopping: nothing announced the stop,
so there was no moment before the tunnel came down at which to run it.
Three cases where nothing fires, deliberately:
- The first connection and a normal shutdown.
starting/startedare about moving to a different gateway; the initial connect prints its URLs andstopping/stoppedon Ctrl+C would delay the exit for a hook whose work is already done. - A client pinned with
-pin, forwarning_receivedandstopping. A pinned client never fails over (#1275), so it is not warned off a gateway and never moves ahead of one. It does now reconnect to its own gateway after a restart (#1946), and that firesstopped→starting→startedlike any other re-established session. - A hook you have not configured. No shell is spawned.
Contextual environment variables¶
Every hook receives all five, always, plus the environment the client itself was started
with. Values that are not knowable for that event are empty (or 0):
| Variable | Value |
|---|---|
LFT_EVENT |
The event name: warning_received, stopping, stopped, starting, started. |
LFT_NODE_ID |
The gateway that announced a stop. Empty when the move was not triggered by an announcement. It keeps naming the gateway being left for the rest of the sequence. |
LFT_SECONDS_REMAINING |
Seconds until the announced stop, counting down as the move proceeds. 0 when no stop has been announced. |
LFT_FAILOVER_REGION |
The region now serving the tunnel. Set on started; empty on the others, because the destination is not elected until the move is underway. |
LFT_SUBDOMAIN |
The leased subdomain prefix. |
What a hook can and cannot do¶
- A hook cannot veto a transition. Its exit status is logged and discarded. The move has already been decided by the time a hook runs, and letting a script refuse one would leave the tunnel down with no way to recover it. A failing hook does not stop the next one.
- A hook is bounded at 15 seconds. After that it is killed and the client carries on. The
bound covers a hook that backgrounds a child as well, so a stray
&cannot wedge the client. - The four session hooks run in order, one at a time, so
stoppedhas finished beforestartingbegins. The cost of that guarantee is that a slow hook delays the move by up to its 15-second bound.warning_receivedis the exception: it runs concurrently, because the point of the warning is to move sooner, and blocking the heartbeat to run a script would work against that. - Failures are visible. Every run is logged, with the hook's combined output on failure.
Client Logs & Diagnostics¶
The client keeps persistent logs under ~/.lfr-tunnel/logs/, for both foreground and background runs. They are the first place to look when a tunnel misbehaved and the terminal output is gone.
| File | Contents |
|---|---|
traffic-<subdomain>.log |
One JSON object per proxied HTTP request: timestamp, method, path, status, duration, target port and the region serving it. |
error-<subdomain>.log |
Structured diagnostic events — failover, failback, lease eviction, exhausted regions — with the fields explaining each. |
client-<subdomain>.log |
Console output from a -background run. |
All three are JSON Lines and are rotated rather than overwritten: the previous run is kept as .1, up to three generations, each capped at 8 MiB. A client that exits unexpectedly leaves its log behind instead of erasing it on the next start.
Because they are JSON Lines, they can be filtered directly:
# Every request that failed
jq 'select(.status >= 400)' ~/.lfr-tunnel/logs/traffic-your-name-se.log
# What happened during the last region switch
jq 'select(.event | startswith("failover"))' ~/.lfr-tunnel/logs/error-your-name-se.log
# Slowest requests first
jq -s 'sort_by(-.dur_ms) | .[0:10]' ~/.lfr-tunnel/logs/traffic-your-name-se.log
Recording request and response bodies¶
Bodies are not written by default. They routinely carry OAuth tokens, session cookies and customer data, and these files persist on disk. When you need them — debugging an incoming webhook payload, for instance — enable them explicitly:
lfr-tunnel -subdomain your-name-se -log-bodies
Bodies are capped at 10 KB each. Prefer the Inspector at http://localhost:4040 for casual payload inspection: it holds the last 100 requests in memory only, so nothing reaches disk.
Need Help?¶
- Common Errors:
[Error] Unauthorized: Your token may be invalid or revoked. Check~/.lfr-tunnel/tokenand verify your token is copied correctly.[Error] Subdomain already registered: Another active user is currently using the requested subdomain prefix. Try a different-subdomainflag.- Detailed Guides:
- For advanced setup and Liferay virtual host configurations, see the Liferay SE Guide.
- For details on self-hosting your own gateway, see the Server Setup Guide.
Last Updated: 2026-09-20 | Last Reviewed: 2026-09-20