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 published Docker image
Section titled “The published Docker image”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.
# Pin a specific version (recommended for production)docker pull plugwerk/plugwerk-server:1.0.0
# Latest stable releasedocker pull plugwerk/plugwerk-server:latest
# Pre-release (e.g. alpha, beta) — never aliased to :latestdocker pull plugwerk/plugwerk-server:1.0.0-beta.3Tag strategy
Section titled “Tag strategy”| 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.
Run options
Section titled “Run options”| 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.
Option 1: Docker Compose (recommended)
Section titled “Option 1: Docker Compose (recommended)”Bundles Plugwerk Server with PostgreSQL into a single stack.
Pin the image version
Section titled “Pin the image version”The compose snippet below uses ${PLUGWERK_VERSION:-latest}. Export an explicit version for reproducible deployments:
export PLUGWERK_VERSION=1.0.0Generate secrets
Section titled “Generate secrets”export PLUGWERK_AUTH_JWT_SECRET="$(openssl rand -base64 32)"export PLUGWERK_AUTH_ENCRYPTION_KEY="$(openssl rand -base64 32)"docker-compose.yml
Section titled “docker-compose.yml”This file is auto-synced from the canonical compose file that ships with each Plugwerk release. Download it directly: /deploy/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:docker compose up -dcurl http://localhost:8080/actuator/healthdocker compose down # stop, keep datadocker compose down -v # stop and delete database volumeCustom JVM options
Section titled “Custom JVM options”The published image reads the standard JAVA_OPTS environment variable. Override it from the host shell or from docker-compose.override.yml:
services: plugwerk-server: environment: JAVA_OPTS: "-Xms512m -Xmx2g"Option 2: Standalone Docker
Section titled “Option 2: Standalone Docker”Run Plugwerk Server as a single container against your own PostgreSQL, S3-compatible storage, or identity provider.
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:
docker run -e JAVA_OPTS="-Xms512m -Xmx2g" plugwerk/plugwerk-server:latestOption 3: JAR Execution
Section titled “Option 3: JAR Execution”Run Plugwerk Server directly on a JVM (21+) using the provided start script. Download the distribution ZIP from GitHub Releases, extract it, and start:
unzip plugwerk-server-*.zipcd plugwerk-server-*/chmod +x start.sh./start.shThis requires an external PostgreSQL database and optionally external S3 storage.
Customize the JVM with JAVA_OPTS:
export JAVA_OPTS="-Xms512m -Xmx1g -XX:+UseZGC"./start.shRunning behind a reverse proxy
Section titled “Running behind a reverse proxy”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. |
Default behaviour: no proxy
Section titled “Default behaviour: no proxy”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.
Configuring the trusted-proxy ranges
Section titled “Configuring the trusted-proxy ranges”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.
# Single nginx on the same host as the Plugwerk containerexport 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 proxyexport PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS=10.0.0.0/16,52.95.245.0/24Identifying 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. |
Worked example: nginx on the same host
Section titled “Worked example: nginx on the same host”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:
export PLUGWERK_AUTH_TRUSTED_PROXY_CIDRS=127.0.0.1/32The 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.
CIDR validation at startup
Section titled “CIDR validation at startup”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'): ...Environment Variables
Section titled “Environment Variables”See Configuration for the full environment variable reference.