Getting Started
Installation
Install the Localport agent on macOS, Linux, or Windows from Homebrew, apt, dnf, apk, a container image, the install script, or source.
Summary
Install the agent Recommended#
Run the command for your operating system.
Install with Homebrew:
brew install localport/tap/localport
Or use the install script:
curl -fsSL https://localport.io/install.sh | shcurl -fsSL https://localport.io/install.sh | sh
On Debian, Ubuntu, Raspberry Pi OS, RHEL, Rocky Linux, Amazon Linux, Fedora and Alpine, run as root or with sudo, the script adds the Localport package repository and installs the localport package. Upgrades then come through apt, dnf or apk, which verify every package against the repository key.
In PowerShell:
irm https://localport.io/install.ps1 | iex
On Windows a fresh install goes under %LOCALAPPDATA%\Programs\localport and adds that folder to your PATH. No administrator rights needed.
Open a new terminal afterwards
Everywhere else the script installs the binary. It checks the download against the release's checksums.txt, and checksums.txt against the release signature when the machine has ssh-keygen from OpenSSH 8.1 or later, which macOS, Windows 10 and later, and most Linux systems do. A mismatched checksum or a bad signature aborts the install. On macOS and Linux the binary lands in /usr/local/bin, falling back to ~/.local/bin when that is not writable, and sudo is used only when it is needed.
Apart from the repository entry and its key on the Linux distributions above, the script changes nothing else: no shell profile, no background service, no telemetry. Both scripts are short enough to read first, at install.sh and install.ps1.
The same command installs and upgrades. On a machine that already has the agent, the script replaces the binary at the path it already occupies, so you never end up with two copies. If that binary is already the release being installed, the script says so and downloads nothing. A localport package from the Localport repository is upgraded through its package manager.
Any other agent that a package manager put there is left alone. The script recognises Homebrew, Nix, Snap, apt, dnf, pacman and apk on macOS and Linux, and Scoop, Chocolatey and winget on Windows. It stops and prints that manager's own upgrade command. Overwriting files a package manager owns leaves it out of step, and its next upgrade puts its own build back.
A binary the script installed before the package repository existed stays a binary install at its own path. To move a Debian, RHEL-family or Alpine machine onto the repository, delete that binary and run the script again.
After installing, the script checks which localport your shell resolves. If an older copy sits earlier on your PATH, it names that file so you can delete it.
An agent installed by an earlier version of the script is found and upgraded even when your PATH does not reach it. The one exception is an install you placed somewhere custom with LOCALPORT_INSTALL_DIR: set the same variable again to upgrade it, or the script treats the machine as a fresh install.
Three environment variables change what gets installed and where.
| Variable | Effect |
|---|---|
LOCALPORT_VERSION | Install a specific release tag, such as v0.8.0, instead of the latest stable |
LOCALPORT_INSTALL_DIR | Install the binary to a directory you choose, on macOS, Linux and Windows. Skips the package repository |
LOCALPORT_FORCE | Reinstall even when the installed binary is already current |
LOCALPORT_INSTALL_DIR="$HOME/bin" curl -fsSL https://localport.io/install.sh | sh
Pin the version on servers and in CI images
LOCALPORT_VERSION to a release tag. On a machine you rebuild often, an unexpected version change is worse than an old version.Install from the package repository#
pkg.localport.io serves signed apt, dnf and apk repositories for x86_64 and ARM64, plus 32-bit ARM for Debian-family systems and Alpine. The install script sets it up for you. Set it up by hand when you provision machines with Ansible, cloud-init or an image build, where a pinned, declarative source beats running a script.
Each repository carries the last 10 stable releases. Prereleases are published on GitHub only.
Covers Debian, Ubuntu, Raspberry Pi OS and their derivatives. The key is scoped to this repository with signed-by, so it vouches for nothing else on the machine.
sudo install -d -m 0755 /usr/share/keyrings
curl -fsSL https://pkg.localport.io/localport-archive-keyring.gpg \
| sudo tee /usr/share/keyrings/localport-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/localport-archive-keyring.gpg] https://pkg.localport.io/deb stable main" \
| sudo tee /etc/apt/sources.list.d/localport.list
sudo apt-get update
sudo apt-get install localport localport-archive-keyring
localport-archive-keyring owns the key file from then on, so a future key change arrives as an ordinary package upgrade. Pin a version with sudo apt-get install localport=0.8.0-1.
Covers RHEL 8 and later, Rocky Linux, AlmaLinux, Amazon Linux 2023, Fedora and their derivatives. dnf checks both the repository metadata and each package against the key.
sudo curl -fsSL -o /etc/pki/rpm-gpg/RPM-GPG-KEY-localport \
https://pkg.localport.io/RPM-GPG-KEY-localport
sudo tee /etc/yum.repos.d/localport.repo >/dev/null <<'EOF'
[localport]
name=Localport
baseurl=https://pkg.localport.io/rpm
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-localport
EOF
sudo dnf install localport localport-archive-keyring
dnf asks you to confirm the key the first time. Pin a version with sudo dnf install localport-0.8.0.
Covers Alpine Linux 3.18 and later, including Alpine-based container images.
wget -qO /etc/apk/keys/localport-2026.rsa.pub https://pkg.localport.io/localport-2026.rsa.pub
echo https://pkg.localport.io/alpine/stable >> /etc/apk/repositories
apk add localport
Pin a version with apk add localport=0.8.0-r1.
Upgrade with apt upgrade localport, dnf upgrade localport or apk upgrade localport, alongside the rest of the system.
Install manually from a release#
Every release publishes the binaries and a checksums.txt covering all of them.
Verify it against checksums.txt, then install it. macOS adds a quarantine flag to anything downloaded from a browser, which xattr clears:
shasum -a 256 --ignore-missing -c checksums.txt
chmod +x localport-darwin-*
xattr -d com.apple.quarantine localport-darwin-*
sudo mv localport-darwin-* /usr/local/bin/localportsha256sum --ignore-missing -c checksums.txt
chmod +x localport-linux-*
sudo mv localport-linux-* /usr/local/bin/localport
ARM64 covers a Raspberry Pi 3 or newer on a 64-bit operating system, the default on current Raspberry Pi OS. ARM 32-bit covers every Raspberry Pi on a 32-bit operating system, including the Pi Zero and Pi 1, and other ARMv6 and ARMv7 boards.
Compare the hash against checksums.txt, then rename the file and clear the block Windows puts on downloads:
Get-FileHash localport-windows-amd64.exe -Algorithm SHA256
Rename-Item localport-windows-amd64.exe localport.exe
Unblock-File localport.exe
Move localport.exe into a folder on your PATH, or add its folder to the PATH environment variable.
Check the release signature#
A checksum proves the file matches checksums.txt. The signature proves checksums.txt is the one Localport released. Each release carries three independent proofs, and any one of them is enough.
The release key. A maintainer signs checksums.txt with a key held in hardware. Download checksums.txt.sig and allowed_signers next to checksums.txt, then check it with OpenSSH 8.1 or later. The command is the same on macOS, Linux and Windows:
ssh-keygen -Y verify -f allowed_signers -I release@localport.io \
-n localport-release -s checksums.txt.sig < checksums.txt
Build provenance. Every file has a SLSA provenance attestation naming the workflow, commit and tag that built it. Check one with the GitHub CLI:
gh attestation verify localport-linux-amd64 --repo localport/agent \
--signer-workflow localport/agent/.github/workflows/release.yml
Sigstore. checksums.txt.sigstore.json is a keyless signature by the release workflow, checked with cosign 3.1.3 or later:
cosign verify-blob checksums.txt --bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github\.com/localport/agent/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Each release also publishes an SPDX software bill of materials, localport.spdx.json, listing the Go modules the agent is built from.
Build it from source#
The agent is the only part of Localport that runs on your machine, and its source is public under the Apache 2.0 licence. Building it yourself takes two commands.
Requires Go 1.27 or newer.
git clone https://github.com/localport/agent.git
cd agent
make build
./bin/localport version
make build-all cross-compiles every published platform into bin/. Releases are built with -trimpath from the same targets, so a binary you build matches the one we publish in behaviour and origin.
Run the agent as a background service#
A tunnel is live only while the agent is running. On a laptop you leave it in a terminal. On a server, a device in the field, or a box at home, you want it started at boot and restarted if it stops.
The repository ships both service templates, already hardened.
Take deploy/localport.service from the repository.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin localport
sudo install -d -m 0700 -o localport -g localport /etc/localport
printf '%s' 'tok_...' | sudo tee /etc/localport/token >/dev/null
sudo chmod 0600 /etc/localport/token
sudo cp localport.service /etc/systemd/system/
sudo systemctl enable --now localport
Edit the ExecStart= line to match the service you are forwarding, then check it with systemctl status localport and journalctl -u localport -f.
The token is never put on the command line. systemctl show, ps, and the unit file itself are readable by every local account, so a token in ExecStart= is a token every user on that machine has. The unit uses systemd's LoadCredential=, which places the token at a file readable only by the service user, and points LOCALPORT_TOKEN_FILE at it. The unit also runs unprivileged with ProtectSystem=strict, NoNewPrivileges=true, and a restricted set of address families.
Take deploy/io.localport.agent.plist from the repository.
Create the service account the daemon runs as. The UID is the next free one below 500, where macOS keeps hidden system accounts:
ID=$(( $(dscl . -list /Users UniqueID | awk '$2 < 500 {print $2}' | sort -n | tail -1) + 1 ))
sudo dscl . -create /Groups/_localport PrimaryGroupID "$ID"
sudo dscl . -create /Users/_localport UniqueID "$ID"
sudo dscl . -create /Users/_localport PrimaryGroupID "$ID"
sudo dscl . -create /Users/_localport RealName "Localport agent"
sudo dscl . -create /Users/_localport UserShell /usr/bin/false
sudo dscl . -create /Users/_localport NFSHomeDirectory /var/empty
sudo dscl . -create /Users/_localport IsHidden 1
Then install the daemon. Its state, its token and its log are owned by that account.
sudo install -d -m 0700 -o _localport -g _localport /Library/Application\ Support/localport
printf '%s' 'tok_...' | sudo tee /Library/Application\ Support/localport/token >/dev/null
sudo chown _localport:_localport /Library/Application\ Support/localport/token
sudo chmod 0600 /Library/Application\ Support/localport/token
sudo touch /var/log/localport.log
sudo chown _localport:_localport /var/log/localport.log
sudo cp io.localport.agent.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/io.localport.agent.plist
sudo chmod 0644 /Library/LaunchDaemons/io.localport.agent.plist
sudo launchctl load -w /Library/LaunchDaemons/io.localport.agent.plist
launchd has no equivalent of LoadCredential=, so the token goes in a file with mode 0600 that only this account reads, named by LOCALPORT_TOKEN_FILE. Keep it out of ProgramArguments, where ps shows it to every local account. launchd also has no sandboxing keys, so UserName is what bounds the daemon: it forwards a port above 1024 and needs nothing more. Logs go to /var/log/localport.log.
Windows has no service template in the repository. Use the Task Scheduler with a trigger of At startup, running localport.exe with your arguments, or wrap it with a service manager such as NSSM.
Set the token through the LOCALPORT_TOKEN or LOCALPORT_TOKEN_FILE environment variable on the task. A token on the command line appears in the task definition and in a process listing.
Run the agent in a container#
The official image is ghcr.io/localport/agent, for linux/amd64, linux/arm64 and linux/arm/v7. It holds the release binary, CA certificates and nothing else, and runs as an unprivileged user (UID 65532). Tags are the release version, such as 0.8.0, and latest for the newest stable release.
docker run --rm -e LOCALPORT_TOKEN=tok_... ghcr.io/localport/agent:0.8.0 \
http 3000 --local http://host.docker.internal:3000 --region eu
Pass the token as an environment variable or a Docker secret. An argument shows up in docker inspect and in a process listing. A container reaching a service on the host needs host.docker.internal on macOS and Windows, or --network host on Linux.
Credentials from localport setup or localport login live in /home/nonroot/.localport. Mount a volume there to keep them across container restarts:
docker run -d --restart unless-stopped \
-v localport:/home/nonroot/.localport \
ghcr.io/localport/agent:0.8.0 connect --config /home/nonroot/.localport/localport.yaml
The image is signed by the release workflow. Check the signature with cosign 3.1.3 or later before you deploy it:
cosign verify ghcr.io/localport/agent:0.8.0 \
--certificate-identity-regexp '^https://github\.com/localport/agent/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
To add the agent to an image of your own, copy the binary from the official image by digest, which Docker checks on pull:
COPY --from=ghcr.io/localport/agent:0.8.0@sha256:<digest> /usr/bin/localport /usr/local/bin/localport
On an Alpine-based image, install the package from the repository instead, so apk verifies it.
Verify, upgrade, and remove#
localport version
That prints the version, the commit it was built from, and the build date. localport --version does the same. If your shell says the command was not found, the install directory is not on your PATH: /usr/bin for a package, /usr/local/bin for the script on macOS and Linux, ~/.local/bin when the script fell back to it, and %LOCALAPPDATA%\Programs\localport on Windows.
Upgrading is the same command you installed with. Homebrew uses brew upgrade localport, and a package from the repository upgrades with the rest of the system through apt, dnf or apk. The install script finds the binary your shell resolves, compares it against the release you asked for, and replaces it only if the two differ. Set LOCALPORT_FORCE=1 to reinstall a binary that is already current. Running tunnels keep going on the version they started with until you restart the agent.
Removing it is deleting the binary. brew uninstall localport for Homebrew, sudo apt-get remove localport, sudo dnf remove localport or apk del localport for a package, otherwise sudo rm /usr/local/bin/localport, or delete the folder under %LOCALAPPDATA%\Programs on Windows. To remove the repository as well, delete /etc/apt/sources.list.d/localport.list, /etc/yum.repos.d/localport.repo or the pkg.localport.io line in /etc/apk/repositories.
If this machine holds credentials from localport setup or localport login, clear them too: localport identity remove <selector> for one, or delete ~/.localport for all of them.
Deleting a credential does not revoke it
Frequently asked questions#
Which operating systems and processors does the agent support?
The agent runs on macOS 13 Ventura or later, Windows 10 or Windows Server 2016 or later, and Linux with kernel 3.2 or later. Every release publishes six builds: macOS on Apple Silicon and Intel, Linux on x86_64, ARM64 and 32-bit ARM (ARMv6 and ARMv7), and Windows on x86_64. Other platforms that Go targets can be built from source.
Do I need administrator or root access to install it?
No. On Windows the script installs to your user profile and needs no elevation at all. On macOS and Linux it uses sudo only to set up the package repository or to write to /usr/local/bin, and falls back to ~/.local/bin in your home directory when sudo is not available. You can also set LOCALPORT_INSTALL_DIR to any directory you can write to.
How do I check the binary I downloaded has not been tampered with?
Every release publishes a checksums.txt listing the SHA-256 of each file, signed by the release key. The install script checks both and aborts on a mismatch, and packages from the repository are checked by apt, dnf or apk. To check a manual download, run sha256sum --ignore-missing -c checksums.txt on Linux, shasum -a 256 --ignore-missing -c checksums.txt on macOS, or Get-FileHash on Windows, then check the release signature.
Does the agent install a background service or a system extension?
No. Installing puts one binary on disk, plus the repository entry and its key when installed as a package. There is no daemon, no launch item, no kernel extension, and no telemetry agent. It runs when you run it. Setting it up to start at boot is a separate, deliberate step covered above.
Can I install it without a package manager or an install script?
Yes. Download the binary for your platform from the releases page, verify its checksum, make it executable, and put it somewhere on your PATH. It has no dependencies to resolve, so nothing else is required.
Does it run on a Raspberry Pi or another single-board computer?
Yes. The linux-arm64 build covers a Raspberry Pi 3 or newer on a 64-bit operating system, the default on current Raspberry Pi OS. The linux-arm build covers every Raspberry Pi on a 32-bit operating system, including the Pi Zero and Pi 1. On Raspberry Pi OS the install script sets up the package repository, so the right build is chosen for you.
Where does the agent store its files?
Only credentials, and only if you create some. localport setup and localport login write to ~/.localport, with directories at mode 0700 and files at 0600. Set LOCALPORT_HOME to move that elsewhere. The service templates set it so a system account has somewhere to write. A tunnel that authenticates with a token alone writes nothing.
How do I upgrade to a new version?
Run the install script again, brew upgrade localport if you installed with Homebrew, or upgrade the localport package with apt, dnf or apk. The script upgrades the binary where it already sits, and exits without downloading when that binary is already the version being installed. Nothing is migrated and no configuration is rewritten. A running agent keeps using the version it started with until you restart it.
Why did the install script refuse to upgrade my Homebrew copy?
Because Homebrew owns that file. Replacing it would point Homebrew's records at a build that is no longer there, and brew upgrade would put its own build back the next time it ran. Run brew upgrade localport to update it. If you want a copy the install script manages, set LOCALPORT_INSTALL_DIR to a directory you own and install there. The same applies to Nix, Snap, apt, dnf, pacman, apk, Scoop, Chocolatey and winget.
Can I pin a specific version instead of the latest?
Yes. Set LOCALPORT_VERSION to a release tag before running the install script, on macOS, Linux and Windows, or pin the package version as shown under Install from the package repository. This is what to do on production machines and in CI images, where an unexpected version change is worse than an old version.
Can I audit what the agent does before running it on a work machine?
Yes, and this is the intended path for anyone who has to justify it. The agent's full source is on GitHub under Apache 2.0, builds with make build on Go 1.27, and is the only Localport component that runs inside your network. Its wire protocol is documented in the same repository.
What to read next#
- Quickstart. Create a tunnel and reach it from the internet.
- CLI. Every command, flag, and environment variable.
- Configuration. Run several tunnels from one YAML file.
- HTTP Tunnels. Web apps, APIs, and webhook endpoints.
- Remote Access. Require a client certificate to connect.