Container image¶
ghcr.io/eizedev/pivotkit runs the REST service, the MCP server and the
command line from one install: pivotkit-server, pivotkit-mcp and pivotkit-cli at the
versions of one release, on a distroless Node 22 image (no shell, no package manager), as the user
10001.
| Tag | What it is |
|---|---|
<version>, e.g. the release pivotkit-server@0.1.0 as 0.1.0 |
That release, unchanged |
<major>.<minor> |
The latest release of that minor version |
Images are built for linux/amd64 and linux/arm64 from the release's commit, signed with cosign (keyless) and carry a provenance and an SBOM attestation (below).
Run it¶
The image runs pivotkit-server serve --host 0.0.0.0 on port 3333 with the configuration file
/config/pivotkit.json (REST, MCP: one file for both). On every
address of the container it starts only with sign-in (auth.mode token or oauth). A minimal
configuration (paths are inside the container):
{
"sources": { "sales": { "file": "/data/sales.csv", "title": "Sales", "policy": { "fields": { "Region": "show", "Customer": "hide" } } } },
"output": { "dir": "/out", "maxAgeDays": 7 },
"auth": { "mode": "token", "tokens": [ { "name": "reports", "file": "/run/secrets/pivotkit_token" } ] }
}
docker run -d --name pivotkit \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges:true \
-p 127.0.0.1:3333:3333 \
-v "$PWD/data:/data:ro" \
-v "$PWD/pivotkit.json:/config/pivotkit.json:ro" \
-v "$PWD/secrets/pivotkit_token:/run/secrets/pivotkit_token:ro" \
-v pivotkit-out:/out \
ghcr.io/eizedev/pivotkit:<version>
| Path | What goes there |
|---|---|
/config/pivotkit.json |
The configuration ($PIVOTKIT_CONFIG names another) |
/data |
The data files, mounted read-only |
/out |
The output directory, writable by the user 10001 (a named volume is; a host folder needs chown 10001:10001) |
/run/secrets/ |
Token files, readable by the user 10001 |
/tmp |
A tmpfs: the root file system stays read-only |
Keep tokens in files (secrets), not in environment variables: docker inspect shows a container's
environment. A secret file is mounted with its owner and mode from the host, so it must be readable
by the user 10001: owned by 10001 with chmod 0400 (sudo chown 10001 secrets/*) keeps it from the
host's other users; chmod 0644 is the way without root; check-config and the start say so when it is
not. Set the port with PIVOTKIT_PORT, not http.port in the file: the health check asks
$PIVOTKIT_PORT. A token file is read on every request, so a new token works without a restart.
docker/compose.example.yml and docker/pivotkit.example.json are an example for Docker Compose with these settings: two tokens as secrets, one with a profile that may download files.
Commands¶
The image's entry point is pivotkit-server; the command after the image name is its subcommand:
docker run --rm ghcr.io/eizedev/pivotkit:<version> --help
docker run --rm -v "$PWD/pivotkit.json:/config/pivotkit.json:ro" -v "$PWD/data:/data:ro" -v "$PWD/secrets/pivotkit_token:/run/secrets/pivotkit_token:ro" -v pivotkit-out:/out ghcr.io/eizedev/pivotkit:<version> check-config # the mounts of the example above
docker run --rm --read-only -v "$PWD:/work" -w /work ghcr.io/eizedev/pivotkit:<version> cli render view.json --data sales.csv -o report.xlsx
docker run --rm -i -v "$PWD/pivotkit.json:/config/pivotkit.json:ro" -v "$PWD/data:/data:ro" ghcr.io/eizedev/pivotkit:<version> mcp
mcp runs the MCP server over stdio (for a client that starts the container itself), reading
/config/pivotkit.json too. cli writes where you mount a writable folder. serve --enable rest
serves REST only; the MCP endpoint is on by default.
Health¶
The image has a HEALTHCHECK (pivotkit-server healthcheck, every 30 seconds): it asks
/healthz, which answers while the process runs. /readyz answers 200 only when the server can take
work (the OAuth issuer loaded, the workers up, /out writable) and 503 otherwise; use it as a
readiness probe. On docker stop (SIGTERM) the server takes no new connection and lets running
reports finish for up to 25 seconds (http.graceMs); give the container more than that
(--stop-timeout 30, stop_grace_period: 30s).
Behind a proxy, with OAuth¶
Set http.publicUrl to the address clients use and keep the proxy's Host header (or add its name
to http.allowHosts); the server answers no other host name. With a proxy or a published port in
front, every client comes from one address: set http.trustProxy with a proxy that writes
X-Forwarded-For (the nginx example), so the limit on refused tokens counts per
client. With OAuth the MCP endpoint and the
REST service are two resources with their own audiences:
{
"auth": { "mode": "oauth",
"issuer": "https://login.example.com/realms/example",
"resource": "https://reports.example.com/mcp",
"requiredScopes": ["pivotkit"],
"scopeProfiles": { "pivotkit:export": "export" } },
"rest": { "resource": "https://reports.example.com/api/v1" },
"policy": { "profiles": { "export": { "results": true } } },
"http": { "publicUrl": "https://reports.example.com/" }
}
The provider must put https://reports.example.com/api/v1 into the aud of tokens for the REST
service (an audience mapper in Keycloak, the application ID URI in Entra ID; see
MCP: OAuth for provider examples), and the image must reach the issuer over HTTPS.
Until it has loaded the issuer's keys, /readyz is 503. It tries again after 2, 4, 8 seconds and so
on, at most every minute, so after a long outage of the issuer it can take up to a minute to be
ready again.
Kubernetes¶
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
containers:
- name: pivotkit
image: ghcr.io/eizedev/pivotkit:<version>
ports: [{ containerPort: 3333 }]
securityContext: { readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } }
readinessProbe: { httpGet: { path: /readyz, port: 3333, httpHeaders: [{ name: Host, value: localhost }] } }
livenessProbe: { httpGet: { path: /healthz, port: 3333, httpHeaders: [{ name: Host, value: localhost }] } }
volumeMounts:
- { name: config, mountPath: /config, readOnly: true }
- { name: data, mountPath: /data, readOnly: true }
- { name: out, mountPath: /out }
- { name: tokens, mountPath: /run/secrets, readOnly: true }
- { name: tmp, mountPath: /tmp }
terminationGracePeriodSeconds: 30
A probe sends the pod's IP as its Host by default, which the server refuses: send localhost as
above. The limits of the REST service are counted per process: with more than one replica each counts
on its own.
Check the image¶
Each release's image is signed by this repository's image workflow with cosign's keyless signing. Check the signature, then the attestations (with cosign 3 and Docker's buildx; check the options against their current documentation):
cosign verify ghcr.io/eizedev/pivotkit:<version> \
--certificate-identity-regexp '^https://github\.com/eizedev/pivotkit/\.github/workflows/image\.yml@refs/tags/pivotkit-server@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
docker buildx imagetools inspect ghcr.io/eizedev/pivotkit:<version> --format '{{ json .Provenance }}'
docker buildx imagetools inspect ghcr.io/eizedev/pivotkit:<version> --format '{{ json .SBOM }}'
The image is built from the release's commit with npm ci --omit=dev --ignore-scripts from the
repository's lock file, on base images pinned by digest
(docker/Dockerfile).
Without containers¶
The same service runs from npm: npm install --global pivotkit-server pivotkit-mcp, then
pivotkit-server serve --config pivotkit.json, for example as a Windows service next to PowerShell
Universal. See Running it.