Portunus
Deployment

Deploy with Docker

Pull the published GHCR images, mount config and state, and run Portunus in containers.

Docker Compose is the recommended deployment path for most installs. The release pipeline publishes multi-arch Linux images to GitHub Container Registry; the examples below use the :latest tag by default:

docker pull ghcr.io/zingerlittlebee/portunus-server:latest
docker pull ghcr.io/zingerlittlebee/portunus-client:latest

For fully repeatable production deploys, replace :latest with a pinned release tag such as :1.7.0.

Server compose

Create compose.yaml on the server host:

services:
  server:
    image: ghcr.io/zingerlittlebee/portunus-server:latest
    container_name: portunus-server
    ports:
      - "7443:7443"
      - "127.0.0.1:7080:7080"
    volumes:
      - portunus-data:/var/lib/portunus
    command:
      - --data-dir
      - /var/lib/portunus
      - serve
      - --operator-http-listen
      - 0.0.0.0:7080
    restart: unless-stopped

  operator:
    profiles:
      - cli
    image: ghcr.io/zingerlittlebee/portunus-server:latest
    network_mode: "service:server"
    volumes:
      - portunus-data:/var/lib/portunus
    entrypoint:
      - portunus-server
      - --data-dir
      - /var/lib/portunus
    command:
      - list-clients

  volume-tools:
    profiles:
      - ops
    image: alpine:3.20
    volumes:
      - portunus-data:/data
      - .:/backup
    working_dir: /backup
    command:
      - "true"

volumes:
  portunus-data:
    name: portunus-data

Start the server:

docker compose up -d server

The named volume keeps generated TLS material, the SQLite database, and other daemon-owned state under /var/lib/portunus. If /var/lib/portunus/server.toml is absent, the server uses built-in defaults. The Web UI and operator API bind to 0.0.0.0:7080 inside the container and are published only on the server host loopback interface as http://127.0.0.1:7080/. The client control-plane listener remains reachable on 0.0.0.0:7443.

For first-run onboarding, read the setup token from the running server logs:

docker compose logs server | grep 'Portunus onboarding setup token'

Open http://127.0.0.1:7080/, paste the setup token, and create the first superadmin password. The token expires after 30 minutes and is rotated every time the server starts while onboarding is incomplete.

If you need overrides, mount an optional server.toml into the same data dir:

services:
  server:
    volumes:
      - portunus-data:/var/lib/portunus
      - ./server.toml:/var/lib/portunus/server.toml:ro

The interactive installer (curl … | bash) can manage a Docker Compose deployment end to end — see Installation → Interactive manager.

Operator commands

The runtime image is distroless, so it has no shell. The operator service is a one-shot CLI sidecar that shares the server network namespace and the same named volume. docker compose run overrides the service command, so each invocation can run a different subcommand:

Operator commands fall into two groups, and they have very different runtime requirements.

HTTP API commands (push-rule, list-rules, remove-rule, rule-stats) talk to the running server's operator HTTP API, so they require an API token and can run while server is up. After Web onboarding, create a token from the Web UI Credentials page and pass it as PORTUNUS_OPERATOR_TOKEN:

docker compose run --rm \
  -e PORTUNUS_OPERATOR_TOKEN="$PORTUNUS_OPERATOR_TOKEN" \
  operator push-rule edge-01 18080 backend.internal:8080

Store-direct commands (enroll-client, revoke, list-clients, reset-password, onboarding-token) open the SQLite store directly and take an exclusive lock. They do not read PORTUNUS_OPERATOR_TOKEN, and they cannot run while server holds the store — a running server makes them exit with store_in_use (exit code 75). Stop server first:

docker compose stop server
docker compose run --rm operator enroll-client edge-01
docker compose up -d server

Run the printed portunus-client enroll 'portunus://...' command on the edge host.

onboarding-token is mainly an offline maintenance command; a later serve start rotates the setup token again, so browser onboarding normally uses the token printed in the server logs. reset-password follows the same stop-server pattern:

docker compose stop server
docker compose run --rm operator reset-password admin --temporary
docker compose up -d server

Use the actual superadmin user ID. For bootstrap-superadmin installs that ID is _superadmin; for Web onboarding it is the ID chosen during setup, for example admin.

Client

The client image's entrypoint is already portunus-client, so pass enroll … as arguments to override the default CMD. Run both the one-shot enroll and the long-lived container as your host user — the image defaults to nonroot (UID 65532), which cannot write a host-owned bind mount nor read a host-UID 0600 bundle:

# One-shot: redeem the enrollment command into a host file.
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" \
  ghcr.io/zingerlittlebee/portunus-client \
  enroll 'portunus://...' --out /work/client.bundle.json

# Long-lived forwarder.
docker run -d --name portunus-client --network host \
  --user "$(id -u):$(id -g)" \
  -v "$PWD/client.bundle.json:/etc/portunus/client.bundle.json:ro" \
  ghcr.io/zingerlittlebee/portunus-client

Compose equivalent (compose.client.yaml), after the bundle exists:

services:
  client:
    image: ghcr.io/zingerlittlebee/portunus-client:latest
    container_name: portunus-client
    network_mode: host
    user: "${HOST_UID}:${HOST_GID}"
    volumes:
      - ./client.bundle.json:/etc/portunus/client.bundle.json:ro
    restart: unless-stopped

Start it with HOST_UID=$(id -u) HOST_GID=$(id -g) docker compose -f compose.client.yaml up -d. Host networking is the least surprising mode for portunus-client: pushed rules bind listeners on the edge host and Docker cannot know those ports before the operator creates the rules. install.sh is host-only — containers use the image directly. Binary and enroll details: Client Configuration.

Backup & migration

With the server state in one named volume, backup and migration are volume operations plus the small Compose project files:

tar -czf portunus-config.tar.gz compose.yaml
docker compose stop server
docker compose run --rm volume-tools \
  tar -czf /backup/portunus-data.tar.gz -C /data .
docker compose start server

Restore on a new host:

tar -xzf portunus-config.tar.gz
docker compose run --rm volume-tools \
  tar -xzf /backup/portunus-data.tar.gz -C /data
docker compose up -d server

If you use an override file, back up server.toml alongside compose.yaml.

Volumes & paths

The recommended Compose example uses one writable named volume:

VolumeContainer pathPurpose
portunus-data/var/lib/portunus/Generated TLS material, optional server.toml, state.db, and sidecar files

Local image build

The repository also includes deploy/docker/docker-compose.yml for building runtime images from a local checkout. That compose file is for development and release verification, not the recommended install path:

./deploy/docker/prepare-build-context.sh
docker compose -f deploy/docker/docker-compose.yml build

The helper builds inside rust:1.88-bookworm so the copied binaries are compatible with the Debian 12 distroless runtime image. Do not copy binaries from a newer host distribution into these images; that can produce GLIBC_2.38 not found failures at container startup.

Considerations

  • Distroless runtime has no shell. Use docker compose run --rm operator for Portunus CLI commands, or use a temporary sidecar container for file inspection.
  • Operator API / Web UI exposure is a two-step decision: the process binds 0.0.0.0:7080 inside the container so Docker can publish it, while the sample maps that port to host loopback only.
  • Filesystem class check still fires inside containers — make sure the Docker volume backend is persistent local storage.
  • portunus-client needs the bundle present at /etc/portunus/client.bundle.json (or PORTUNUS_CLIENT_BUNDLE).

Health checks

Do not use list-clients (or any other store-direct command) as a HEALTHCHECK probe. It opens the SQLite store with an exclusive lock, so running it periodically against a live server fights the server for that lock and fails with store_in_use (exit code 75) — the container would never report healthy.

The distroless runtime image also has no shell, curl, or wget, so a container-internal CMD probe is impractical. Instead, drive liveness from the orchestration layer with a TCP connect check against a listening port — for example the operator HTTP listener on 127.0.0.1:7080 or the metrics listener on 127.0.0.1:7081 — or an external probe. In Compose this means a healthcheck that runs a TCP check from a sidecar or relies on your orchestrator's native probes rather than an in-image CMD.

Networking notes

  • The control-plane listener (7443) is the only port that must be exposed for cross-host clients.
  • The operator HTTP listener (7080) binds to 0.0.0.0 inside the container so Docker can publish it, but the sample maps it to host loopback only: 127.0.0.1:7080:7080.
  • The metrics listener (7081) remains loopback-only.

On this page