Skip to content

Deployment

qnop is one container plus two backing services. This page covers what to run and how to put it behind a proxy; Configuration covers every setting it reads.

The released image is published to two registries:

RegistryImagePublished
GHCRghcr.io/qnophq/qnop-cealways — this is the guaranteed one
Docker Hubqnophq/qnop-cewhen the release workflow has its credentials

Both are multi-arch (linux/amd64, linux/arm64), built on eclipse-temurin:21 and run as the non-root user qnop.

Each release publishes three tags: the exact version, the major.minor line, and latest. Production deployments pin the exact version. Rolling builds of main go to GHCR only, as :main and immutable :sha-… tags — useful for evaluation, not for production.

ComponentPurpose
qnopREST API and the embedded web UI, one port
PostgreSQLall relational data
S3-compatible object storagethe document binaries

The reference deploy/docker-compose.yml in the main repository wires these together with PostgreSQL and MinIO for a single host. Any S3-compatible endpoint works — the storage layer is an SPI implementation, see Extending qnop.

The container serves everything on port 8080: /api/v1/** is the REST API, /actuator/** is the operational surface, and every other path falls through to the single-page UI embedded in the same jar. There is no separate frontend to deploy.

The qnop container itself is stateless and needs no volume. State lives entirely in PostgreSQL and the bucket.

Terminate TLS at a proxy in front of qnop. Two settings matter, and missing the second one is the common mistake:

Terminal window
# Trust X-Forwarded-* from the proxy, so redirects and cookies use the public URL
QNOP_HTTP_FORWARD_HEADERS_STRATEGY=framework
# Tell the rate limiter which hop to trust for the client IP
QNOP_AUTH_RATE_LIMIT_TRUSTED_PROXY_CIDRS=10.0.0.0/8

The refresh-token cookie is issued Secure by default, so the browser will not return it over plain HTTP. Plain HTTP is for evaluation only; a real deployment terminates TLS.

CORS usually needs nothing: the UI is served from the same origin as the API. QNOP_CORS_ALLOWED_ORIGINS exists for the split dev-server setup and defaults to a localhost value that production should not keep.

GET /actuator/health is anonymous and safe to point a load balancer at. The liveness and readiness groups are enabled:

EndpointUse
/actuator/healthoverall status
/actuator/health/livenessrestart-if-failing probe
/actuator/health/readinessroute-traffic-to-me probe

Health details — and every other actuator endpoint, metrics included — require an authenticated ADMIN. Only the bare status is public.

The reference compose file probes every 15 s with a 60 s start period, which accommodates schema migration on first boot.

On first start qnop bootstraps the admin account. Unless you set QNOP_ADMIN_PASSWORD, it generates a one-time password, writes it to the container’s stderr and to /tmp/qnop-admin-password.txt (mode 0600) inside the container, and flags the account so the password must be changed at first login:

Terminal window
docker compose logs qnop | tail -5

Setting QNOP_ADMIN_PASSWORD skips both the generated password and the forced change — convenient for automated provisioning, and one more secret to manage.

Liquibase migrates the schema forward automatically on startup; Hibernate never generates DDL. This means a new image can be started against an existing database and will bring it up to date on its own.

The entrypoint already sets -XX:MaxRAMPercentage=75.0 and -XX:+ExitOnOutOfMemoryError, so the container sizes its heap from the memory limit you give it and dies rather than thrashing. Add further flags through JDK_JAVA_OPTS.