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 image
Section titled “The image”The released image is published to two registries:
| Registry | Image | Published |
|---|---|---|
| GHCR | ghcr.io/qnophq/qnop-ce | always — this is the guaranteed one |
| Docker Hub | qnophq/qnop-ce | when 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.
What it needs
Section titled “What it needs”| Component | Purpose |
|---|---|
| qnop | REST API and the embedded web UI, one port |
| PostgreSQL | all relational data |
| S3-compatible object storage | the 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.
Behind a reverse proxy
Section titled “Behind a reverse proxy”Terminate TLS at a proxy in front of qnop. Two settings matter, and missing the second one is the common mistake:
# Trust X-Forwarded-* from the proxy, so redirects and cookies use the public URLQNOP_HTTP_FORWARD_HEADERS_STRATEGY=framework
# Tell the rate limiter which hop to trust for the client IPQNOP_AUTH_RATE_LIMIT_TRUSTED_PROXY_CIDRS=10.0.0.0/8The 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.
Health checks
Section titled “Health checks”GET /actuator/health is anonymous and safe to point a load balancer at. The
liveness and readiness groups are enabled:
| Endpoint | Use |
|---|---|
/actuator/health | overall status |
/actuator/health/liveness | restart-if-failing probe |
/actuator/health/readiness | route-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.
First start
Section titled “First start”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:
docker compose logs qnop | tail -5Setting QNOP_ADMIN_PASSWORD skips both the generated password and the forced
change — convenient for automated provisioning, and one more secret to manage.
Schema migration
Section titled “Schema migration”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.
JVM tuning
Section titled “JVM tuning”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.