Go backend for the EA QMS Change Control module — 21 CFR Part 11 compliant workflow API.
325
Production Docker image for the backend API of the EA QMS Change Control module.
docker pull 20dumpling/ea-qms-backend:1.2.2
docker pull 20dumpling/ea-qms-backend:latest
docker run -d --name ea-qms-api -p 1304:1304 \
-e DB_URL="postgres://postgres:[email protected]:5432/ea_qms?sslmode=disable" \
-e JWT_SECRET="your_secure_random_signing_secret" \
-e PLATFORM="dev" \
-e ALLOWED_ORIGINS="http://localhost:5173" \
-e DB_MAX_OPEN_CONNS="25" \
20dumpling/ea-qms-backend:latest
⚠ The database must already have the schema. This image is the API alone and runs
no migrations, so against an empty database it starts and then fails on the first
query. Either point it at a database that already has the schema, or use the Compose
stack in deploy/docker/,
which brings up PostgreSQL, applies the Goose migrations and seeds four dev accounts
before the API starts. That is the easiest way to run the whole thing.
Note the database host. Inside a container, localhost is the container itself — not
your machine. Use host.docker.internal to reach a PostgreSQL server running on the host
(add --add-host=host.docker.internal:host-gateway if you are on Docker Engine rather
than Docker Desktop). If PostgreSQL runs in another container, use its service or
container name instead.
If you pass configuration with --env-file rather than -e, do not quote the values.
Docker does no quote processing on that file, so JWT_SECRET="abc" becomes a secret with
literal quote characters. The -e form above is fine because the shell strips them.
A dedicated, unauthenticated health probe is available at GET /api/healthz for Docker
and orchestrator readiness/liveness checks. It actively verifies the live database
connection pool (rawDB.PingContext).
curl http://localhost:1304/api/healthz
# Returns: {"status":"ok","database":"connected"}
| Variable | Required | Description |
|---|---|---|
DB_URL | Yes | PostgreSQL connection string |
JWT_SECRET | Yes | Secret key used to sign and verify JWT tokens. Every replica must share the same value, or a token issued by one will be rejected by another |
PLATFORM | No | Environment mode (dev, prod). Defaults to dev. |
ALLOWED_ORIGINS | No | Comma-separated list of allowed CORS origins. See below |
DB_MAX_OPEN_CONNS | No | Connection-pool ceiling per process. Defaults to 10. See below |
INSTANCE_ID | No | Identifies which instance served a request. Defaults to the hostname, which under Docker is the container ID |
ARGON2ID_* | No | Tunable password hashing parameters (Memory, Iterations, etc.) |
JWT_SECRETIt signs every access token, so it must be long and random. Anyone who knows it can mint a valid token for any user.
openssl rand -base64 64
Or, without OpenSSL:
head -c 64 /dev/urandom | base64 -w 0
Every instance must share the same value — a token signed by one is verified by another. And changing it invalidates every token in circulation, which signs everyone out.
ALLOWED_ORIGINS and the silent failureIt must match the browser's origin exactly — scheme, host and port.
http://localhost:5173 does not cover http://127.0.0.1:5173, and a trailing
slash will not match either.
⚠ Getting it wrong looks like the API being down. Postman and curl keep working, because they are not browsers and do not enforce CORS; the browser silently blocks every call. If it is empty, the API logs a warning at startup.
A browser client served from the same origin as the API — behind a reverse proxy, for instance — needs no value at all.
The API is stateless — configuration comes from the environment, sessions are JWTs, and nothing is held in memory between requests — so it scales horizontally without changes. Two variables matter when it does.
DB_MAX_OPEN_CONNS is per process, not per deployment. Each instance holds its own
connection pool and cannot see its peers, while PostgreSQL enforces one global
max_connections (default 100). Three instances at 25 is 75 connections; a fourth
exceeds the budget, and the failure appears at the database rather than at the service
that caused it. Size it as max_connections ÷ instances, with headroom.
INSTANCE_ID is how you tell instances apart. It is attached to every log line and
returned as an X-Instance-ID response header, so a request can be traced to the process
that served it and an aggregated log stream can be filtered per instance. Set it
explicitly — docker run --name does not set a container's hostname, so the fallback
yields a container ID rather than a readable name.
docker run -d --name ea-qms-api-1 --network qms-net \
--env-file .env -e INSTANCE_ID=api-1 \
20dumpling/ea-qms-backend:1.2.2
Multi-stage build: a static Linux binary (CGO_ENABLED=0) stripped of DWARF symbols,
packed onto alpine:latest — about 7 MB. The API documentation is compiled in with
go:embed, so it ships with the code it describes. Only the compiled binary crosses from
the build stage; no source and no configuration reach the published image.
Published for linux/amd64 and linux/arm64 under one tag. docker pull resolves
to the right variant automatically, so Apple Silicon and ARM servers run natively rather
than under QEMU emulation. The build cross-compiles with GOARCH=$TARGETARCH, which
costs nothing in Go — there is no emulated build step.
Content type
Image
Digest
sha256:d83539cba…
Size
6.8 MB
Last updated
11 days ago
docker pull 20dumpling/ea-qms-backend