Skip to content

Deployment

Plugwerk Server is published as a multi-architecture container image and as a standalone JAR. The container path — run via Docker Compose or directly with docker run — is the recommended one for production. The JAR is supported for environments without Docker.

The Plugwerk Server image is published on Docker Hub at plugwerk/plugwerk-server for linux/amd64 and linux/arm64. Every v* Git tag publishes a matching image tag.

Terminal window
# Pin a specific version (recommended for production)
docker pull plugwerk/plugwerk-server:1.0.0
# Latest stable release
docker pull plugwerk/plugwerk-server:latest
# Pre-release (e.g. alpha, beta) — never aliased to :latest
docker pull plugwerk/plugwerk-server:1.0.0-beta.3

| Tag | When published | Example | | --------------------- | ------------------------------------ | ------------------------ | | {{version}} | Every release (stable & pre-release) | 1.0.0, 1.0.0-beta.3 | | {{major}}.{{minor}} | Every release | 1.0 | | latest | Stable releases only (semver-aware) | latest |

Pre-releases (alpha, beta, rc) never update :latest. For reproducible deployments always pin a full {{version}} tag.

The image is multi-arch — docker pull resolves the correct manifest for linux/amd64 and linux/arm64 automatically. The bundled JVM means no host JVM is required.

The image needs a PostgreSQL database and a small set of environment variables. Pick one of the run options below.

| Aspect | Docker Compose | Standalone Docker | JAR Execution | | -------------------- | --------------------- | --------------------- | ------------- | | Setup effort | Minimal | Low | Medium | | Third-party services | Included (PostgreSQL) | External | External | | Docker required | Yes | Yes | No | | JVM required | No (bundled in image) | No (bundled in image) | Yes (JVM 21+) |

All three options default to local-filesystem artifact storage under a Docker volume / mounted path. For multi-replica or cloud-managed deployments where a persistent volume is impractical, switch to S3-compatible object storage — see Storage backends.

Bundles Plugwerk Server with PostgreSQL into a single stack.

The compose snippet below uses ${PLUGWERK_VERSION:-latest}. Export an explicit version for reproducible deployments:

Terminal window
export PLUGWERK_VERSION=1.0.0
Terminal window
export PLUGWERK_AUTH_JWT_SECRET="$(openssl rand -base64 32)"
export PLUGWERK_AUTH_ENCRYPTION_KEY="$(openssl rand -base64 32)"

This file is auto-synced from the canonical compose file that ships with each Plugwerk release. Download it directly: /deploy/docker-compose.yml.

docker-compose.yml
# ---------------------------------------------------------------------------
# Plugwerk server — minimal local/dev compose template.
#
# This file is bundled in the server distribution as a starting point. It is
# NOT production-ready as-is:
#
# - The Postgres credentials (POSTGRES_PASSWORD, PLUGWERK_DB_PASSWORD) are
# placeholder values. Replace them with strong secrets before any non-
# local-dev use.
# - PLUGWERK_AUTH_JWT_SECRET and PLUGWERK_AUTH_ENCRYPTION_KEY are passed through from
# the host environment (`${VAR:?…}`) and fail fast if missing.
# - The admin bootstrap password is auto-generated on first start and
# printed to `docker compose logs plugwerk-server`; `passwordChangeRequired`
# is set, so the operator is forced to change it on first login. To pin
# a fixed admin password for CI/smoke-test environments, export
# `PLUGWERK_AUTH_ADMIN_PASSWORD` in your own shell or `.env` before
# running `docker compose up` — never commit that value.
#
# See the Docker Hub README for a guided setup (strong secret generation,
# login instructions, operational caveats).
# ---------------------------------------------------------------------------
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: plugwerk
POSTGRES_USER: plugwerk
POSTGRES_PASSWORD: plugwerk
ports:
- "127.0.0.1:5432:5432"
volumes:
# postgres:18+ requires the volume at /var/lib/postgresql (not /var/lib/postgresql/data)
# so PGDATA can live in a major-version-specific subdirectory alongside config.
# See https://github.com/docker-library/postgres/pull/1259.
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U plugwerk"]
interval: 5s
timeout: 5s
retries: 5
plugwerk-server:
image: plugwerk/plugwerk-server:${PLUGWERK_VERSION:-latest}
depends_on:
postgres:
condition: service_healthy
environment:
PLUGWERK_DB_URL: jdbc:postgresql://postgres:5432/plugwerk
PLUGWERK_DB_USERNAME: plugwerk
PLUGWERK_DB_PASSWORD: plugwerk
PLUGWERK_AUTH_JWT_SECRET: "${PLUGWERK_AUTH_JWT_SECRET:?Set PLUGWERK_AUTH_JWT_SECRET (min 32 chars)}"
PLUGWERK_AUTH_ENCRYPTION_KEY: "${PLUGWERK_AUTH_ENCRYPTION_KEY:?Set PLUGWERK_AUTH_ENCRYPTION_KEY (min 16 chars, 32+ recommended)}"
# PLUGWERK_AUTH_ADMIN_PASSWORD is intentionally NOT declared here.
# If unset, the server generates a random admin password on first start
# and forces a change on first login. Export it only for CI / smoke-test
# environments where a deterministic password is required; the process
# environment is picked up by Spring Boot without needing a compose entry.
volumes:
- plugwerk-artifacts:/var/plugwerk/artifacts
ports:
- "8080:8080"
volumes:
postgres-data:
plugwerk-artifacts:
Terminal window
docker compose up -d
curl http://localhost:8080/actuator/health
Terminal window
docker compose down # stop, keep data
docker compose down -v # stop and delete database volume

The published image reads the standard JAVA_OPTS environment variable. Override it from the host shell or from docker-compose.override.yml:

docker-compose.override.yml
services:
plugwerk-server:
environment:
JAVA_OPTS: "-Xms512m -Xmx2g"

Run Plugwerk Server as a single container against your own PostgreSQL, S3-compatible storage, or identity provider.

Terminal window
docker run -d \
--name plugwerk-server \
-p 8080:8080 \
-e PLUGWERK_DB_URL=jdbc:postgresql://your-db-host:5432/plugwerk \
-e PLUGWERK_DB_USERNAME=plugwerk \
-e PLUGWERK_DB_PASSWORD=changeme \
-e PLUGWERK_AUTH_JWT_SECRET="$(openssl rand -base64 32)" \
-e PLUGWERK_AUTH_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
-v plugwerk-artifacts:/var/plugwerk/artifacts \
plugwerk/plugwerk-server:${PLUGWERK_VERSION:-latest}

Pass JAVA_OPTS the same way as in Compose:

Terminal window
docker run -e JAVA_OPTS="-Xms512m -Xmx2g" plugwerk/plugwerk-server:latest

Run Plugwerk Server directly on a JVM (21+) using the provided start script. Download the distribution ZIP from GitHub Releases, extract it, and start:

Terminal window
unzip plugwerk-server-*.zip
cd plugwerk-server-*/
chmod +x start.sh
./start.sh

This requires an external PostgreSQL database and optionally external S3 storage.

Customize the JVM with JAVA_OPTS:

Terminal window
export JAVA_OPTS="-Xms512m -Xmx1g -XX:+UseZGC"
./start.sh

Plugwerk Server's per-IP rate limiting (login brute-force protection on /api/v1/auth/login and /api/v1/auth/refresh) keys off the client IP that the server observes. When a reverse proxy sits in front of Plugwerk, every request appears to come from the proxy's IP — every client lands in the same rate-limit bucket and the protection collapses.

To restore correct per-client buckets, the server reads the original client IP from the leftmost X-Forwarded-For value — but only when the request actually came from a proxy you have explicitly trusted. Trust is configured via:

| Variable | Default | Description | | ----------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | | PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS | (empty) | Comma-separated CIDR ranges. X-Forwarded-For is honoured only when the connecting peer's IP falls inside one of these. |

With the variable left empty, X-Forwarded-For is ignored entirely and Plugwerk uses the connection's remoteAddr. This is the correct default for a server reachable directly by clients (no proxy in front), and prevents a forged header from rotating fake IPs to bypass rate limiting.

If you run with no proxy, do nothing — the default is safe.

Set PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS to the egress IP range(s) of the proxy that fronts Plugwerk. Each entry must be valid CIDR syntax; both IPv4 and IPv6 are supported. The list is validated at startup — an invalid entry fails fast with a clear error.

Terminal window
# Single nginx on the same host as the Plugwerk container
export PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS=127.0.0.1/32
# Internal LAN (Kubernetes pod CIDR, Docker bridges, internal load balancers)
export PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# AWS ALB egress range plus a backup proxy
export PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS=10.0.0.0/16,52.95.245.0/24

Identifying the egress IP for common stacks

Section titled “Identifying the egress IP for common stacks”

| Stack | Suggested CIDR | Notes | | ----------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | nginx on the same host | 127.0.0.1/32 | Plugwerk in Docker with nginx on the host: use the Docker bridge gateway (typically 172.17.0.1/32) instead. | | Kubernetes (nginx / Traefik) | Pod CIDR (e.g. 10.244.0.0/16) | Look up your cluster's pod CIDR with kubectl cluster-info dump \| grep -m1 cluster-cidr. | | AWS ALB | The VPC CIDR (e.g. 10.0.0.0/16) | ALB egress IPs are ENIs inside the VPC. Pinning the VPC CIDR is simpler and equivalent in practice. | | Cloudflare in front of nginx | The local proxy's CIDR, not Cloudflare's | Plugwerk only sees the immediate hop. Configure set_real_ip_from on nginx for Cloudflare ranges, then trust nginx's IP from Plugwerk. |

/etc/nginx/sites-available/plugwerk.conf
server {
listen 443 ssl http2;
server_name plugwerk.example.com;
# ... TLS config omitted ...
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Forward the original client IP. Plugwerk reads the leftmost value.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
}
}

Then on the Plugwerk side:

Terminal window
export PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS=127.0.0.1/32

The connection from nginx to Plugwerk arrives on 127.0.0.1, matches the trusted CIDR, and Plugwerk reads the real client IP from X-Forwarded-For. Per-IP rate limiting now applies per real client.

To verify, hit the server from two different external IPs and confirm that each is throttled independently — the rate limit (default: 10 attempts / 15 min on /auth/login) should not be shared between them.

Plugwerk parses every entry in PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS through Spring Security's IpAddressMatcher at startup. A bare IP without a prefix is accepted and treated as /32 (IPv4) or /128 (IPv6); blank entries and unparseable values abort the boot with an indexed message identifying the bad entry, for example:

plugwerk.auth.trusted-proxy-cidrs[2] is not a valid CIDR range ('10.0.0.300/24'): ...

See Configuration for the full environment variable reference.