Skip to content

Sharing & Tunnels: Exposing Liferay to the Internet

LDM provides a unified interface (ldm share) to securely expose your local Liferay environment or containers to the public internet. This is essential for:

  • Testing external webhooks (e.g., payment gateways, external CI pipelines).
  • Integrating with SaaS services that require a callback URL.
  • Sharing your local development environment with colleagues or clients.

Sharing Providers

LDM supports two distinct tunneling providers:

1. lfr-tunnel (Default)

lfr-tunnel is a lightweight, host-side Go client that routes traffic from public wildcard subdomains (*.lfr-demo.se and *.lfr-demo.online) directly to your local ports.

  • Pros: Runs natively on the host, lowest CPU overhead, supports wildcard routing.
  • Requirements: LDM automatically downloads and updates the binary on the first invocation.

Warning

EDR / SentinelOne Quarantine: Because lfr-tunnel (host-side) is a Go binary downloaded dynamically to the host, corporate endpoint protection tools (such as SentinelOne) may flag it as an untrusted threat and quarantine it. This will prevent LDM from starting the tunnel and cause the error: ❌ Failed to verify lfr-tunnel installation after download.

The Solution: If you encounter EDR interference, use the lfr-tunnel-docker (Zero-Install) provider instead. It runs the exact same client inside the isolated project Docker Compose network, bypassing host-level EDR checks entirely. Set it via --share-provider lfr-tunnel-docker on startup:

ldm run <project> --share --share-provider lfr-tunnel-docker --share-subdomain pjrtest

2. lfr-tunnel-docker (Zero-Install)

Runs the exact same lfr-tunnel client, but completely isolated as a service sidecar inside your project's docker-compose.yml stack.

  • Pros: 100% immune to SentinelOne/EDR host-level alerts that block unknown Go binaries on macOS/Windows. Runs in sync with the project's lifecycle (ldm run / ldm stop), requiring zero manual background process management.
  • Resource Constraints: Optimized to consume absolute minimal resources (CPU is restricted to a maximum of 0.10 cores with 0.05 reservations, and memory is bound to a maximum of 50M with 20M reservations).
  • Direct Tomcat Bypass: Directly connects to the liferay container internally on Tomcat's port 8080 (rather than routing through the Traefik proxy), which resolves Host header/Virtual Host url conflicts for external redirect services.

Conceptual Flow

  Public User
      β”‚ (HTTPS via leased subdomain: e.g., my-subdomain.lfr-demo.se)
      β–Ό
  VPS Server Gateway (lfr-tunneld)
      β”‚ (WebSockets over TCP)
      β–Ό
  lfr-tunnel Client (Docker Sidecar Service in Project Network)
      β”‚ (Bypasses local Nginx/Traefik proxy completely)
      β–Ό (Internal Docker Network Routing)
  liferay Container (Tomcat Port 8080)

3. ngrok

ngrok runs as a sidecar container inside your project's Docker Compose stack, creating a secure tunnel to ngrok's edge servers.

  • Pros: Fully self-contained, isolated within the project stack.
  • Requirements: Requires a free ngrok account and Auth Token.

Direct CLI Usage (ldm share)

You can manage tunnels at any time using the ldm share command group.

Start a Tunnel

To share a running project context:

ldm share start [project] --provider lfr-tunnel --subdomain my-subdomain --ports 8080
  • If --provider is omitted, LDM defaults to lfr-tunnel.
  • If --domain is omitted, the client picks a gateway itself by latency and fails over automatically if that gateway goes down. Naming a known gateway with --domain pins it, which disables both β€” useful when you need a specific region, but the tunnel will stay down for the duration if that gateway stops. LDM warns when a run is pinned.
  • If --subdomain is omitted, it defaults to the subdomain carried by --domain (see What --domain accepts below), and failing that to the project name.
  • LDM prints the address it is about to claim before it connects, and where each half of it came from:
β„Ή  Public URL: https://peters.lfr-demo.se
β„Ή    subdomain 'peters' from the command line, domain 'lfr-demo.se' from --domain

The second line matters more than it looks. The resolution order is a flag, then the project's meta, then ~/.ldmrc, then a prompt, then LDM's default -- and two of those are written by LDM itself, so a value can be in play that nobody remembers choosing. A domain arriving from a machine-wide pin now says so:

β„Ή    subdomain 'peters' from the command line, domain 'dev.example.com' from ~/.ldmrc
  • If --ports is omitted, the tunnel client auto-discovers what to expose: running Docker containers first, then common Liferay ports, then the client-extension ports declared by the workspace. Passing --ports narrows it to exactly what you name, so omit it unless you need that.

What --domain accepts

--domain takes three different kinds of value, and LDM tells them apart rather than requiring you to know which is which:

You pass LDM reads it as Result
lfr-demo.se a known gateway base domain <subdomain>.lfr-demo.se, gateway pinned
peters.lfr-demo.se a host on a known base domain subdomain peters on lfr-demo.se, gateway pinned
dev.example.com a custom vanity domain <subdomain>.dev.example.com, no pin
default the standard gateway <subdomain>.lfr-demo.online, ignoring any stored domain

A custom vanity domain only affects the public URL; the client always dials a real Liferay gateway, and the domain must be registered and approved in the Liferay Tunnel portal before it will resolve.

Asking for the standard gateway

The domain is sticky. LDM records it in the project's meta and in ~/.ldmrc, so a custom domain set once is reused by every later ldm share start for that project -- including one where you only passed --subdomain:

ldm share start my-project --subdomain peters
#  -> still uses the custom domain pinned weeks ago

Pass default (or standard) to ask for the standard gateway instead:

ldm share start my-project --subdomain peters --domain default

This also replaces the domain stored for that project, so subsequent runs use the standard gateway too. It does not alter ~/.ldmrc; the project's own value takes precedence over it either way.

When the domain you asked for did not take

An unregistered custom domain does not fail -- the gateway routes you on the standard domain instead. LDM announces a public URL before the tunnel starts and resolves the real one afterwards, so when those differ it now says so:

βœ…  🌍 Public Tunnel Active: https://peters.lfr-demo.se
⚠️  The tunnel came up on https://peters.lfr-demo.se, not the
    https://peters.dev.example.com announced above. Use the address on this line.
πŸ’‘  Next step: 'dev.example.com' is a custom domain, and the gateway routes one
    only once it has been registered and approved in the Liferay Tunnel portal.

Previously both URLs were printed and neither was reconciled with the other, so the first -- which does not resolve -- was indistinguishable from fact (LDM-#2191).

If you already have the URL, pass it whole and let LDM take it apart:

ldm share start my-project --url https://peters.lfr-demo.se

--url is shorthand for --subdomain plus --domain; pass one or the other, not both. If --subdomain and the subdomain implied by --domain disagree, LDM refuses rather than picking one for you.

Where the known base domains come from

LDM ships with lfr-demo.online and lfr-demo.se as a seed, and extends it with whatever the gateway says it answers on. On a real ldm share start LDM reads supported_domains from the gateway's unauthenticated /api/version endpoint and caches it for 24 hours, so a domain added to the hosted gateways is picked up without an LDM release.

Three properties are deliberate:

  • The seed is extended, not replaced. The advertised list is the set of names a gateway answers on and carries no preference; the seed's order decides which gateway LDM defaults to. The live gateways advertise the seed pair in the opposite order, so substituting one for the other would quietly change your default gateway.
  • --dry-run never fetches. The list is refreshed only on a real run, so ldm share start --dry-run resolves a subdomain, a domain and a public URL with no client, no token and no network.
  • A gateway that advertises nothing changes nothing. An older gateway, or one whose operator has declined to publish the list, simply leaves the seed in place.

tunnel_base_domains, a JSON list in ~/.ldmrc, still replaces the lot and suppresses the fetch entirely. That remains the mechanism for a self-hosted Liferay Tunnel gateway, whose names LDM has no way to discover -- the fetch goes to the gateway the seed names, or to an explicitly pinned one (LFT_SERVER_URL).

Note

Answering the interactive "Choose sharing domain" prompt stores your answer in ~/.ldmrc as a machine-wide default, and ldm share start then copies the resolved value into each project's meta. Resolution order is the flag, then the project meta, then ~/.ldmrc -- so clearing the global value alone will not change a project that has already been shared once. The provenance line printed beside the public URL names whichever of these answered, so you do not have to work it out.

Check Tunnel Status

To inspect the active tunnel and retrieve its public URL:

ldm share status [project]

ldm share status reports the same provenance as ldm share start, because it is where you look when an address has already surprised you:

  ● Subdomain: peters
  ● Address from: subdomain 'peters' from the live tunnel, domain 'lfr-demo.se' from ~/.ldmrc

The subdomain is attributed to whichever answered -- the live tunnel, the project's meta, or the project name -- and the domain to the first of the flag, the project's meta or ~/.ldmrc that holds one.

status resolves nothing and writes nothing. It will not prompt for a domain the way share start does, and will not persist one to ~/.ldmrc, so reading the status can never change what the next share does.

Stop a Tunnel

To terminate the active sharing session:

ldm share stop [project]

Automatic Run Integration (ldm run --share)

Instead of starting the tunnel manually, you can tell LDM to automatically boot the sharing tunnel as soon as your Liferay container becomes healthy using the --share flag:

ldm run my-project --share --share-subdomain custom-sub --share-provider lfr-tunnel

Sharing without reconfiguring (ldm start --share)

ldm run reconfigures the project before booting it -- it regenerates the compose file, re-resolves dependency versions and re-applies configuration. For a project that is already configured and simply needs to be up and shared, that is more than you asked for, and anything that fails in the reconfigure stops you sharing at all.

ldm start boots the existing containers without reconfiguring, and takes the same share flags:

ldm start my-project --share --share-url https://peters.lfr-demo.se

Sharing is auxiliary to the start: the project comes up, and then it is shared. ldm start --share is ldm start followed by ldm share start -- it saves the second command rather than changing what either one does, and the tunnel opens only once every container has started.

--share cannot be combined with --all: a tunnel leases one subdomain and forwards to one target, and the refusal arrives before anything is started.

Default Subdomain

Without --share-subdomain, LDM derives one from the project name. A DNS label may contain only lowercase ASCII letters, digits and hyphens, so the derived value is normalised accordingly -- it is not simply the project name:

Project Derived subdomain
my-project my-project
My_Project my-project
SaarbrΓΌcken saarbruecken
poc.client poc-client

Underscores and dots become hyphens, the result is lowercased and truncated to 63 characters, and leading or trailing hyphens are removed. If nothing usable remains, no default is sent and the provider assigns one -- pass --share-subdomain explicitly if you need a specific name.

This is stricter than the sanitisation applied to Docker container names, which permits _, . and mixed case.

Expose Alias (Ngrok Legacy)

For backward compatibility, the --expose flag remains supported and behaves as an alias for --share --share-provider ngrok:

ldm run my-project --expose

Toggling Routing Modes (ldm config ssl-mode)

When developing with LDM, you may want to toggle your project network routing between:

  • Local Hosts-based SSL Mode: Testing offline using a local custom domain name (e.g. https://my-project.local mapped in /etc/hosts with certificates managed via mkcert).
  • Public Share Tunnel Mode: Exposing Liferay publicly via a leased subdomain on a secure tunnel gateway (e.g. https://my-project.lfr-demo.online).

Manually updating Liferay virtual hosts, rebuilding project properties, and updating environment config files inside your client extensions can be error-prone. LDM automates this using the ssl-mode command:

ldm config ssl-mode <hosts | share> [project] [--subdomain <subdomain>] [--domain <domain>] [--no-restart]

Modes

1. Hosts Mode (hosts)

  • Switches the project's metadata ssl parameter to true.
  • Sets the project's host_name to <project_name>.local (or retains your custom local domain).
  • Surgically searches for and updates all local .env files (e.g., LIFERAY_URL, LIFERAY_PORTAL_URL, AICA_LIFERAY_URL) to point to https://<project_name>.local.
  • Rebuilds project properties so Liferay aligns its internal virtual host settings.

2. Share Mode (share)

  • Switches the project's metadata ssl parameter to false (since SSL termination happens at the public tunnel edge).
  • Sets host_name to localhost.
  • Configures your optional --subdomain (defaults to project name) and --domain (defaults to lfr-demo.online).
  • Surgically searches for and updates all local .env files to point to the public tunnel URL (e.g. https://<subdomain>.<domain>).
  • Rebuilds project properties so Liferay maps internal endpoints to the public gateway URL.

Warning

Container Stack Downtime Warning:

Changing routing modes modifies Traefik reverse-proxy bindings, project metadata, and portal-ext.properties. By default, if the project container stack is currently running, changing the routing mode will briefly stop and restart the container stack to apply the changes (causing a short downtime of the Liferay instance).

If you prefer to change the configuration offline or manually manage the container lifecycle later, pass the --no-restart flag:

ldm config ssl-mode hosts my-project --no-restart

Authentication for Liferay Tunnel (lfr-tunnel)

The Liferay Tunnel (lfr-tunnel or lfr-tunnel-docker) requires a Client Token to authenticate with the server gateway. LDM looks for this token in the following order of priority:

  1. Environment Variable: LFT_CLIENT_TOKEN (highest priority). Useful for CI/CD or automated scripts:
export LFT_CLIENT_TOKEN="your_lfr_tunnel_token"
  1. Token File: A flat text file containing only the token saved at ~/.lfr-tunnel/token.
  2. LDM Global Config: Stored in ~/.ldmrc under the lfr_tunnel_token key.
  3. Interactive Prompt: If no token is found, LDM will prompt you to enter it interactively and save it to your ~/.ldmrc global config for future commands.

Securing the Liferay Tunnel Personal Access Token (PAT)

Because LFT_CLIENT_TOKEN grants access to tunnel your local environments, it should be stored securely on your machine.

This approach stores the token in a startup configuration file but restricts its POSIX/Windows ACL permissions so other users or unauthorized local processes cannot read it.

On Linux / macOS (Zsh & Bash)
  1. Create the restricted directory and file:
mkdir -p ~/.config/lfr
touch ~/.config/lfr/secrets
chmod 600 ~/.config/lfr/secrets

(The chmod 600 command ensures only the owner can read or write to this file).

  1. Add the token variable to the file:
echo 'export LFT_CLIENT_TOKEN="your_actual_token_here"' >> ~/.config/lfr/secrets
  1. Source it on shell startup:

Add this to the bottom of your ~/.zshrc or ~/.bashrc:

[ -f ~/.config/lfr/secrets ] && source ~/.config/lfr/secrets
On Windows (PowerShell)
  1. Create the secret file and restrict its ACLs:

Run these commands in PowerShell to create the file and strip away all permissions except for your explicit user account:

# Create the directory and file
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) # Break inheritance
$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
  1. Add the token to the file:
Set-Content -Path "$HOME\.config\lfr\secrets.ps1" -Value '$env:LFT_CLIENT_TOKEN="your_actual_token_here"'
  1. Load the secret automatically on startup:

Open your PowerShell profile (create it if missing via New-Item -Type File -Path $PROFILE -Force):

notepad $PROFILE

Add this line to it:

if (Test-Path "$HOME\.config\lfr\secrets.ps1") { . "$HOME\.config\lfr\secrets.ps1" }

Option 2: The OS Credential Manager Route (Alternative)

This approach stores the token in the OS native credential store (encrypted at rest).

On Linux (Secret Service API / GNOME Keyring)
  1. Store the token:
secret-tool store --label="LFT Token" lfr tunnel_token
# You will be securely prompted to enter the token
  1. Retrieve it automatically on shell startup:

Add this to your ~/.bashrc or ~/.zshrc:

export LFT_CLIENT_TOKEN=$(secret-tool lookup lfr tunnel_token)
On Windows (Credential Manager)
  1. Store the token:
cmdkey /generic:"LFT_CLIENT_TOKEN" /user:"$env:USERNAME" /pass:"your_actual_token_here"
  1. Retrieve it in your $PROFILE:

Since retrieving generic credentials in plain text via PowerShell can be verbose, the Restricted Secrets File approach (Option 1) is generally recommended for Windows CLI environments.


Authentication for Ngrok

Ngrok requires an Auth Token to bind custom host headers and use HTTPS upstreams. The first time you use ngrok, LDM will prompt you for your Auth Token:

  1. Sign up or log in at dashboard.ngrok.com.
  2. Navigate to Getting Started -> Your Authtoken.
  3. Paste the token into the LDM prompt.

LDM will save this token securely in your global configuration (~/.ldmrc), so you will never be prompted for it again across any of your projects.


Tunnel Inspector Dashboard

The Liferay Tunnel container exposes a local web-based Inspector Dashboard. This dashboard allows you to view active tunnels, request/response traffic, and WebSocket latency.

Accessing the Dashboard

When running with lfr-tunnel-docker as your provider, LDM automatically maps port 4040 of the sidecar container to your host machine:

Custom Inspector Host Binding (LFT_INSPECTOR_BIND)

By default, when running inside Docker, the tunnel client binds to 0.0.0.0:4040 to ensure port forwarding works out-of-the-box. When running natively outside Docker, it binds only to 127.0.0.1:4040 for local security.

You can customize the network interfaces the dashboard binds to by setting the LFT_INSPECTOR_BIND environment variable in your local .env file:

# Bind the inspector dashboard to loopback only (inside container)
LFT_INSPECTOR_BIND=127.0.0.1

# Bind to all interfaces (default inside Docker)
LFT_INSPECTOR_BIND=0.0.0.0

Remote Compute Node Tunneling (Zero Inbound Port Security)

When deploying LDM projects to remote cloud compute nodes (e.g. AWS EC2, DigitalOcean, or Azure VMs) via Multi-Node Orchestration (ldm target), running an outbound tunnel sidecar provides a powerful security advantage:

Closing Inbound HTTP/HTTPS Firewall Ports

  1. Outbound Connection: The lfr-tunnel-docker sidecar container running on the remote compute node establishes an outbound encrypted WebSocket connection to a public gateway (*.lfr.live / *.lfr-demo.online).
  2. Firewall Lockdown: Because traffic flows through an outbound socket, you do NOT need to open Inbound HTTP (80) or HTTPS (443) ports in your cloud Security Group / Firewall.
  3. Control Plane Only: The compute node's Security Group only requires Inbound Port 22 (SSH) open (restricted to your developer IP / VPN) for orchestrator CLI commands (ldm target).
# Run project on remote compute node with outbound tunneling enabled
ldm run my-project --target prod-aws --share --share-provider lfr-tunnel-docker --share-subdomain my-remote-demo

Handling SaaS Callbacks (The Host Header)

By default, the sharing tunnels rewrite the Host header to match your project's --host-name (e.g., forge.demo). This ensures that Traefik knows how to route the request to your specific Liferay container.

However, because Liferay "thinks" it is running at forge.demo, it may dynamically generate callback URLs or pagination links using forge.demo instead of your public tunnel domain (e.g., custom-sub.lfr-demo.se). Since external SaaS services cannot resolve forge.demo, those links will fail.

How to Fix This

If your SaaS integration requires Liferay to generate fully qualified URLs (e.g., OAuth redirects), you must temporarily tell Liferay to treat the public tunnel URL as its primary domain:

  1. Log into Liferay as an administrator.
  2. Go to Control Panel -> Instance Settings -> Instance Configuration.
  3. Under Virtual Host, enter your public tunnel domain (e.g., custom-sub.lfr-demo.se or a1b2c3d4.ngrok.app).
  4. Save the configuration.

Liferay will now generate all callback URLs using the public tunnel address.


πŸ› οΈ LDM Sharing Command-Line Switches

When starting or running projects via LDM, you can configure the sharing behavior using the following CLI flags:

1. Stack Execution Flags (ldm run)

  • --share: Enables automatic sharing. Starts a tunnel when the Liferay container reaches a healthy state.
  • --share-provider <lfr-tunnel | lfr-tunnel-docker | ngrok>: Selects the sharing engine. (Defaults to lfr-tunnel host mode).
  • --share-subdomain <name>: Specifies a custom subdomain (e.g., pjrtest). If omitted, defaults to the project name.
  • --share-image <image>: Overrides the sidecar Docker image (useful for testing custom tunnel builds).
  • --share-inspector: Maps the tunnel's local web dashboard to host port 4040 (allows visiting http://localhost:4040).

2. Standalone Share Flags (ldm share start)

  • --provider <lfr-tunnel | lfr-tunnel-docker | ngrok>: Explicitly selects the provider.
  • --subdomain <name>: Requests a specific subdomain.
  • --domain <domain>: A known gateway base domain, a host on one, or a custom vanity domain -- see What --domain accepts above.
  • --url <url>: The public URL to claim, e.g. https://peters.lfr-demo.se. Shorthand for --subdomain and --domain together.
  • --ports <ports>: Overrides target downstream port mapping (e.g. --ports 8080,3000).
  • --image <image>: Overrides sidecar Docker image.
  • --inspector: Exposes the Web Inspector Dashboard on host port 4040.

πŸš€ Advanced lfr-tunnel Binary Switches (Go Client Native Flags)

If you are running the lfr-tunnel Go executable directly or writing custom scripts outside LDM's standard workflow, the native client binary supports the following switches:

1. Connection & Routing

  • -server <url>: Specifies the lfr-tunneld gateway VPS URL (e.g. -server https://tunnel.lfr-demo.se).
  • -token <auth-token>: Authenticates client credentials against the gateway.
  • -subdomain <prefix>: Requests a prefix for public wildcard access (e.g. -subdomain pjrtest).
  • -ports <port,port,...>: Comma-separated downstream local ports to bind/expose (e.g., -ports 8080,3000).
  • -target-host <host/ip>: Points the tunnel upstream destination to a specific IP or hostname (defaults to localhost).
  • -preserve-host: Ingests incoming Host headers down the socket directly instead of rewriting them to target host.

2. Dashboard & Security

  • -inspector-port <port>: Sets a custom port to run the web inspector dashboard on (defaults to 4040).
  • Example: lfr-tunnel -ports 8080 -inspector-port 4045 runs dashboard on http://localhost:4045.
  • -basic-auth <user:pass>: Activates basic authentication on the public HTTPS endpoints to protect your shared sandbox environment from unauthorized public access.
  • -rate-limit <req/sec>: Restricts the maximum throughput routed down the tunnel to protect local servers (0 = unlimited).
  • -add-header "Name: Value": Automatically injects standard HTTP headers into downstream packets (e.g. -add-header "X-Bypass-CORS: true").

3. Background Daemon Control

  • -background: Fork/daemonize the client process into the background. Output logs are redirected to ~/.lfr-tunnel/client.log and the process ID is tracked in ~/.lfr-tunnel/lfr-tunnel.pid.
  • -status: Introspects and outputs the running status/PID of the background daemon.
  • -stop: Sends a shutdown signal (SIGINT) to clean up and close the background tunnel daemon.
  • -upgrade: Triggers a self-upgrade sequence downloading the latest release binary.

Last Updated: 2026-10-19 | Last Reviewed: 2026-10-19