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:latestFor 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-dataStart the server:
docker compose up -d serverThe 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:roThe 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:8080Store-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 serverRun 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 serverUse 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-clientCompose 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-stoppedStart 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 serverRestore 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 serverIf you use an override file, back up server.toml alongside compose.yaml.
Volumes & paths
The recommended Compose example uses one writable named volume:
| Volume | Container path | Purpose |
|---|---|---|
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 buildThe 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 operatorfor 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:7080inside 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-clientneeds the bundle present at/etc/portunus/client.bundle.json(orPORTUNUS_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 to0.0.0.0inside 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.