> ## Content Index
> Fetch the complete content index at: https://blog.uptime.bet/llms.txt
> Use this file to discover other available public pages before exploring further.

# Portr 2.1: Self-Host an ngrok Alternative for Your Team with Docker Compose
- URL: https://blog.uptime.bet/portr-self-hosted-ngrok-alternative-docker/
- Published: 2026-11-07T08:00:00.000Z
- Updated: 2026-11-07T08:00:00.000Z
- Description: Portr is an AGPL-licensed, self-hosted tunnel for teams: expose local HTTP, TCP and WebSocket services, inspect traffic, and manage access from your own server. Version 2.1.0 adds Docker Compose label tunnels and stronger WebSocket multiplexing. Here is how to run it with Docker Compose.
- Author: Alexandre Nebeling
- Tags: Trending, Self-Hosted, Open Source, Docker, Homelab, Server Management, Productivity

Webhook callbacks, client demos and "can you open that PR preview?" moments all share the same awkward step: something on your laptop needs a public URL. Paid SaaS tunnels are easy until the seat count, bandwidth meter or data-residency question shows up. Homelabs that already run their own reverse proxy often want the same workflow on infrastructure they control.

**Portr** is a self-hosted tunnel built for that niche. It exposes local HTTP, TCP and WebSocket services on public URLs, ships a local request inspector with replay, and gives teams an admin dashboard for users and connections. The latest release, **v2.1.0**, landed on **1 November 2026** and adds Docker Compose label tunnels plus stronger WebSocket multiplexing. The project is licensed under **AGPL-3.0**.

In this guide I'll walk through what Portr is, how to deploy the server with Docker Compose, how the client works day to day, and where the honest caveats are.

## What is Portr?

[Portr](https://github.com/amalshaji/portr?ref=blog.uptime.bet) is an open-source tunnel solution for small teams. You run a Portr **server** on a VPS (admin dashboard + tunnel backend + database), install the **client** CLI on developer machines, then expose a local port with a single command:

```bash
portr http 9000

```

That opens a public HTTPS URL that forwards to `localhost:9000`, starts a local inspector at `http://localhost:7777`, and stores request logs under `~/.portr/` so you can query them later with `portr logs`.

The README is explicit about scope: Portr is aimed at development and team sharing. It is **not** recommended as the public edge in front of production application servers.

Under the hood the server can speak either **SSH** or **WebSocket** as the client-to-server tunnel transport (`PORTR_TRANSPORT`). Generated client configs follow that choice, so operators pick one transport for the deployment and clients get matching `ssh_url` or `ws_url` settings.

![Screenshot of the Portr admin dashboard home](https://blog.uptime.bet/content/images/2026/11/portr-dashboard-home.webp)

Portr admin dashboard home view from the upstream repository screenshots. [Source: amalshaji/portr PR screenshots](https://github.com/amalshaji/portr/tree/main/.github/pr-screenshots?ref=blog.uptime.bet) (AGPL-3.0)

## Why Portr is interesting right now

Version **2.1.0** (1 Nov 2026) is a meaningful bump for self-hosters and agent-heavy workflows:

- **`portr docker` / Compose labels** — add one `amalshaji/portr-client` service, label app containers with `portr.enable=true`, and tunnels start and stop with the stack.
- **WebSocket tunnel improvements** — yamux multiplexing, a larger stream window and connection reuse for the WebSocket transport path.
- **Connect page** in the admin console, plus deployment docs for Fly.io and an IaC-style Railway config.
- Prebuilt images on Docker Hub: `amalshaji/portr:2.1.0` (server) and `amalshaji/portr-client:2.1.0` (client), for both `linux/amd64` and `linux/arm64`.

The repository sits around **3.2k stars** at the time of writing and remains actively maintained (v2.1.0 assets published 1 Nov 2026).

## Key features

Going by the README and official docs:

- **HTTP, TCP and WebSocket tunnels** to a public URL you control.
- **Local inspector** on port `7777` for request/response inspection, edited replay and WebSocket frame capture.
- **CLI logs** via `portr logs <subdomain>` (optional `--json`), backed by a local SQLite store.
- **Admin dashboard** for teams, users and live connections; first login becomes the superuser.
- **Reserved subdomains** per team membership (`PORTR_RESERVED_SUBDOMAIN_LIMIT`, default 3; `0` disables new reservations).
- **Optional GitHub OAuth** for the admin UI.
- **Basic auth on HTTP tunnels** (`--basic-auth` / `PORTR_BASIC_AUTH` / per-tunnel config).
- **Host-header rewrite** for frameworks with allowlists (Rails, Django, Vite).
- **Docker Compose label tunnels** with `amalshaji/portr-client` (new in 2.1).
- **SSH or WebSocket transport** selected server-side and mirrored into client configs.

![Screenshot of a Portr HTTP tunnel in the admin UI](https://blog.uptime.bet/content/images/2026/11/portr-dashboard-http.webp)

Portr admin view of an HTTP tunnel connection. [Source: amalshaji/portr PR screenshots](https://github.com/amalshaji/portr/tree/main/.github/pr-screenshots?ref=blog.uptime.bet) (AGPL-3.0)

## Installing the Portr server with Docker Compose

Official production setup uses three services from [docker-compose.prod.yaml](https://github.com/amalshaji/portr/blob/main/docker-compose.prod.yaml?ref=blog.uptime.bet):

1. **caddy** — `ghcr.io/amalshaji/caddy-docker-proxy:main` for routing and DNS-01 TLS (Cloudflare token)
2. **server** — the combined admin + tunnel process (`command: ["start", "all"]`)
3. **postgres** — `postgres:16.2`

All three use `network_mode: "host"` in that file. The upstream file **builds** the server from the repo Dockerfile. For a pinned, pull-based deploy you can swap the `build:` block for the published image `amalshaji/portr:2.1.0` (verified on Docker Hub for this article). If you already run Traefik, Caddy or nginx, skip the bundled Caddy service and terminate TLS yourself — Portr listens on plain HTTP/TCP behind the proxy.

### DNS and ports first

Point your domain at the server (use `example.com` below as a stand-in):

| Type | Name | Value            |
| ---- | ---- | ---------------- |
| A    | @    | your server IPv4 |
| A    | \*   | your server IPv4 |

Do **not** orange-cloud those records through a proxied CDN if you use SSH transport: clients need raw TCP to port **2222**.

| Port        | Purpose                                                                           |
| ----------- | --------------------------------------------------------------------------------- |
| 8000        | Admin dashboard + API (/api/v1/healthcheck)                                       |
| 8001        | Tunnel HTTP proxy (and WebSocket tunnel endpoint when PORTR\_TRANSPORT=websocket) |
| 2222        | SSH tunnel ingress when PORTR\_TRANSPORT=ssh                                      |
| 30001–40001 | Public TCP tunnel range (only if you use TCP tunnels)                             |
| 20000–30000 | Internal HTTP tunnel ports — **firewall these off** the public internet           |

### Step 1: Project folder and env file

```bash
mkdir -p ~/portr/data && cd ~/portr
# data/ must be writable by the non-root server container user

```

Create `.env` based on the upstream template (host networking means Postgres is reached at `localhost`, not a Compose DNS name):

```bash
PORTR_DOMAIN=example.com
PORTR_DB_URL=postgres://postgres:CHANGE_ME_STRONG@localhost:5432/postgres

PORTR_SERVER_URL=https://example.com
PORTR_TRANSPORT=ssh
PORTR_SSH_URL=example.com:2222
PORTR_SSH_PORT=2222
PORTR_SSH_HOST_KEY=
PORTR_WS_URL=tunnel.example.com
PORTR_ADMIN_PORT=8000
PORTR_PROXY_PORT=8001
PORTR_AUTO_MIGRATE=true
PORTR_RESERVED_SUBDOMAIN_LIMIT=3

PORTR_ADMIN_GITHUB_CLIENT_ID=
PORTR_ADMIN_GITHUB_CLIENT_SECRET=

CLOUDFLARE_API_TOKEN=

POSTGRES_USER=postgres
POSTGRES_PASSWORD=CHANGE_ME_STRONG
POSTGRES_DB=postgres

```

Generate a **stable** SSH host key (required for clients that verify host keys across restarts):

```bash
ssh-keygen -t ed25519 -f /tmp/portr_host_key -N ""
# Put the PEM private key into PORTR_SSH_HOST_KEY in .env (multi-line secret / Docker secret)
rm /tmp/portr_host_key /tmp/portr_host_key.pub

```

You can also run `portrd generate-host-key` from a Portr server binary/image when you prefer the project helper.

### Step 2: docker-compose.yml (published image + bundled Caddy)

```yaml
services:
  caddy:
    image: ghcr.io/amalshaji/caddy-docker-proxy:main
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - caddy_data:/data
    restart: unless-stopped
    network_mode: "host"

  server:
    image: amalshaji/portr:2.1.0
    command: ["start", "all"]
    network_mode: "host"
    env_file: .env
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8000/api/v1/healthcheck || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    volumes:
      - ./data:/app/data
    labels:
      caddy_0: $PORTR_DOMAIN
      caddy_0.reverse_proxy: "{{upstreams http 8000}}"
      caddy_0.encode: gzip
      caddy_1: "*.$PORTR_DOMAIN"
      caddy_1.reverse_proxy: "{{upstreams http 8001}}"
      caddy_1.tls.dns: "cloudflare $CLOUDFLARE_API_TOKEN"
      caddy_1.encode: gzip

  postgres:
    image: postgres:16.2
    network_mode: "host"
    environment:
      POSTGRES_USER: $POSTGRES_USER
      POSTGRES_PASSWORD: $POSTGRES_PASSWORD
      POSTGRES_DB: $POSTGRES_DB
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data: {}
  caddy_data: {}

```

Notes:

- Prefer cloning the repo and using upstream `docker-compose.prod.yaml` with `build:` if you want an exact match to the docs path.
- Drop the `caddy` service, labels and `CLOUDFLARE_API_TOKEN` when you terminate TLS yourself. Point `example.com` → `localhost:8000` and `*.example.com` → `localhost:8001`, forward `Host` verbatim, pass WebSocket upgrades, and avoid response buffering on the wildcard vhost ([custom reverse proxy guide](https://portr.dev/docs/server/custom-reverse-proxy?ref=blog.uptime.bet)).
- Certificates must cover both the apex and the wildcard. Session cookies are `Secure`, so the admin UI must be served over HTTPS.

### Step 3: Start and open the admin UI

```bash
docker compose up -d
docker compose ps
curl -fsS https://example.com/api/v1/healthcheck

```

Visit `https://example.com`. The first successful login is treated as the **superuser**. From the overview page, copy the client setup command.

## Client: install, auth, tunnel

Install on macOS/Linux/Windows:

```bash
curl -sSf https://install.portr.dev | sh
# or: brew install amalshaji/taps/portr
# or download the v2.1.0 zip for your OS from GitHub Releases

```

Authenticate with the token from the dashboard:

```bash
portr auth set --token YOUR_TOKEN --remote example.com

```

That writes `~/.portr/config.yaml` with `server_url`, transport settings and your secret. Then:

```bash
portr http 9000
portr http 9000 --subdomain demo
portr http 9000 --host-header rewrite
portr http 9000 --basic-auth admin:s3cret

```

Inspector: `http://localhost:7777`. Logs:

```bash
portr logs demo
portr logs demo /api/ --json

```

### Docker Compose label tunnels (2.1)

For a local stack, add the client sidecar (mount the config created by `portr auth set`):

```yaml
services:
  portr:
    image: amalshaji/portr-client:2.1.0
    volumes:
      - ~/.portr/config.yaml:/root/.portr/config.yaml:ro
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - "7777:7777"

  web:
    build: .
    labels:
      portr.enable: "true"
      portr.subdomain: my-app
      portr.port: "3000"

```

Only containers in the same Compose project with `portr.enable=true` are tunnelled. Mounting the Docker socket is powerful — keep this pattern on developer machines, not on shared production hosts. Set an explicit `portr.subdomain` (for example `${USER}-app`) so two people running the same project against one server do not collide.

## Config tips for a safer setup

- **Pin image tags** (`amalshaji/portr:2.1.0`) instead of floating `latest`.
- **Persist `PORTR_SSH_HOST_KEY`** so clients do not see a new host key after every recreate.
- **Firewall** ports `20000–30000` and keep Postgres / 8000 / 8001 off the public internet when a reverse proxy terminates TLS on 443.
- **Basic auth** on sensitive demo tunnels; remember it is base64 over the wire and only safe because the public URL is HTTPS by default.
- **Watch the VPS** with something you already trust — for example [Uptime Kuma](https://blog.uptime.bet/uptime-kuma-2-self-hosted-uptime-monitoring-docker/) on `https://example.com/api/v1/healthcheck`, and push failures through [ntfy](https://blog.uptime.bet/ntfy-self-hosted-push-notifications-docker/) or [Gotify](https://blog.uptime.bet/gotify-self-hosted-push-notifications-docker/).
- Pair webhook testing with [changedetection.io](https://blog.uptime.bet/changedetection-io-self-hosted-website-change-monitoring-docker/) when you care about upstream content changes, not just inbound callbacks.

## Limitations to keep in mind

- **Dev/team tool, not a production CDN.** The maintainers say so up front. Put real production apps behind a proper reverse proxy and deployment pipeline.
- **DNS and TLS complexity.** You need an apex + wildcard, working certificates, and (for SSH transport) an open TCP 2222 that is not hidden behind a proxied CDN.
- **Host networking in the official compose file** simplifies port binding but is a different operational model than a bridged Compose stack. Plan firewall rules accordingly.
- **AGPL-3.0.** Fine for most self-hosters; if you modify and offer Portr as a network service, read the licence obligations carefully.
- **Basic auth caveats.** CORS preflights without `Authorization` fail; browser credential caching has no logout; inspector replay needs the header set explicitly after redaction.
- **Young-ish surface for agent tooling.** Compose label tunnels and WebSocket multiplexing are new in 2.1 — pin versions and smoke-test your path (HTTP webhook vs TCP vs WS).

## Portr vs common alternatives (short, verified)

|                                  | Portr 2.1                                           | Cloudflare Tunnel (cloudflared)                               | frp                                                              |
| -------------------------------- | --------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- |
| Licence                          | AGPL-3.0                                            | Cloudflare ToS / client Apache-2.0                            | Apache-2.0                                                       |
| Who runs the edge                | You (self-hosted server)                            | Cloudflare network                                            | You (frps)                                                       |
| Team admin UI                    | Built-in dashboard                                  | Cloudflare Zero Trust dashboards                              | Limited / external                                               |
| Local request inspector + replay | Yes (:7777)                                         | Not the same local inspector UX                               | No first-class inspector                                         |
| Best fit                         | Small teams that want an ngrok-like DX on their VPS | Users already on Cloudflare who want outbound-only connectors | Operators who want a minimal, battle-tested reverse proxy tunnel |

Paid ngrok remains the path of least resistance if you do not want to operate DNS, TLS and a tunnel server. Portr's pitch is control: your domain, your audit trail, your team roster.

## Conclusion

Portr is a practical answer when "just use ngrok" stops being comfortable — shared team tunnels, reserved subdomains, a real admin UI and a local inspector that makes webhook debugging less painful. **v2.1.0** makes the Docker Compose day-to-day nicer with label-driven tunnels and hardens the WebSocket transport path.

Deploy the server with Compose (published `amalshaji/portr:2.1.0` or an upstream source build), put HTTPS in front, install the client, and keep the scope honest: development and demos, not your production front door. Used that way, it slots cleanly next to the rest of a homelab monitoring and notification stack.

Are you still on a SaaS tunnel for webhooks, or have you already moved that edge onto your own VPS? Let me know what worked.

## Sources

- [Portr on GitHub (README, features, licence)](https://github.com/amalshaji/portr?ref=blog.uptime.bet)
- [Portr v2.1.0 release notes](https://github.com/amalshaji/portr/releases/tag/v2.1.0?ref=blog.uptime.bet)
- [Portr documentation](https://portr.dev/docs/getting-started?ref=blog.uptime.bet)
- [Server deployment (Docker Compose)](https://portr.dev/docs/server/start-the-tunnel-server?ref=blog.uptime.bet)
- [Custom reverse proxy](https://portr.dev/docs/server/custom-reverse-proxy?ref=blog.uptime.bet)
- [Client installation](https://portr.dev/docs/client/installation?ref=blog.uptime.bet)
- [HTTP tunnel guide](https://portr.dev/docs/client/http-tunnel?ref=blog.uptime.bet)
- [Docker Compose label tunnels](https://portr.dev/docs/client/docker-compose?ref=blog.uptime.bet)
- [Official production compose file](https://github.com/amalshaji/portr/blob/main/docker-compose.prod.yaml?ref=blog.uptime.bet)
- [Docker Hub: amalshaji/portr](https://hub.docker.com/r/amalshaji/portr?ref=blog.uptime.bet)
- [Docker Hub: amalshaji/portr-client](https://hub.docker.com/r/amalshaji/portr-client?ref=blog.uptime.bet)
- [Admin UI screenshots in the repository](https://github.com/amalshaji/portr/tree/main/.github/pr-screenshots?ref=blog.uptime.bet)