Shipcast for Administrators
Deploying Shipcast on infrastructure you own.
The Shipcast README sets the product up on
one machine with two scripts. This page is for the other case: you are
running Shipcast for other people, on a platform that is
already yours — a container host you manage, Kubernetes, a VM under
systemd — where those scripts do not fit. They assume an interactive
terminal, write a .env file, and log in to a registry on
your behalf; none of that is how your platform provisions anything.
So this page does not give you a longer procedure. It gives you the deployment contract: what Shipcast requires of a platform, stated independently of how you start it. Bring those requirements about however your platform brings things about, and Shipcast runs.
You do not need the README to use this page, and a user following the README does not need this page.
Contents
- What you are deploying
- Sizing the server
- Getting the images
- The database
- Configuration
- The secrets you hold
- Network, TLS, and the reverse proxy
- First boot: admin account and license
- A worked deployment
- Operating it
- Verify the deployment
- What Shipcast does not support
What you are deploying
Three containers. Two of them are disposable.
| Component | Image | Listens | Requires | State |
|---|---|---|---|---|
| Frontend | ${REGISTRY_URL}/shipcast/frontend:<tag> |
3002 |
API_URL |
none |
| API | ${REGISTRY_URL}/shipcast/api:<tag> |
3001 |
database connection (see below) | none |
| Database | PostgreSQL 13+ — yours, or the mirror | 5432 |
see The database | everything |
PostgreSQL is the only stateful component. The API and the frontend hold nothing that survives a restart — no config files, no bind mounts, no local cache. Every piece of durable state, including the signing keys the API generates for itself, lives in the database. That single fact settles most platform questions: back up the database, treat the other two as replaceable, and a lost node costs you nothing but the time to reschedule.
The frontend is an nginx serving the compiled web UI and proxying
/api/* to the API. It is the only component that
needs to be reachable from outside. The API and the database
should stay on an internal network.
No outbound connections. Shipcast has no telemetry, no update check, and no phone-home. It reaches the network only to pull images (at deploy time) and, if you configure tracker integration, to reach your own Jira or Azure DevOps host. An installation that does not use tracker integration runs fully air-gapped once the images are on the host.
Sizing the server
Forecasts are the only real load. Everything else — the web UI, sign-in, editing plans, live updates to other viewers — is light, and the frontend and database stay small. What a forecast costs is set by two numbers you can read off your own plan: how many deliverables it holds, and how many runs it asks for (the Runs setting; default 1,000, at most 10,000).
| Your largest plan | Minimum | Recommended | Forecast time on recommended: 1,000 runs | 10,000 runs |
|---|---|---|---|---|
| ≤ 150 deliverables | 1 vCPU / 2 GB | 2 vCPU / 2 GB | ~2 s | ~15 s |
| ~450 deliverables | 1 vCPU / 3 GB | 4 vCPU / 4 GB | ~5 s | ~1 min |
| ~1,150 deliverables | 2 vCPU / 4 GB | 8 vCPU / 8 GB | ~10 s | ~2 min |
| ~1,900 deliverables | 4 vCPU / 6 GB | 8 vCPU / 12 GB | ~20 s | ~3 min |
The figures are for one host running all three containers, with 20 GB of disk. Minimum still finishes a 10,000-run forecast inside the default timeout; Recommended keeps it interactive and leaves room for two forecasts at once. Times assume a typical cloud vCPU and are deliberately conservative — a modern desktop or bare-metal core is roughly twice as fast.
How it scales, if your plans fall between the rows:
- CPU. A forecast uses every core the API is given, and its time falls almost exactly in proportion — double the cores, half the wait. Time grows with deliverables × runs, and a little faster than that for large, heavily dependent plans.
- Memory. One forecast needs about 200 bytes per deliverable per run while it computes — 450 deliverables × 10,000 runs ≈ 0.9 GB — and releases it when it finishes. Add about 1 GB for the operating system, PostgreSQL and the frontend. That is why a plan is capped at 10,000 runs: past that, memory climbs with no gain in forecast accuracy.
- Disk. Give the database a 5 GB volume; the 20 GB above covers it alongside the container images, logs and the operating system. The data itself is small and follows your tracker, not your forecasts: an empty database is about 8 MB, and each tracker item Shipcast has read adds about 3.5 KB (a refresh updates items in place, it does not add copies). A scope of 25,000 tracker items is about 100 MB. Most of the 5 GB is headroom PostgreSQL needs for its own write-ahead log and maintenance. Nothing in Shipcast calls for hundreds of GB.
- Several forecasts at once share the cores, so each takes proportionally longer, and their memory adds up. Size the memory for the number you expect to run simultaneously, not for the number of users.
Keep forecasts well inside the timeout. A forecast
that exceeds SIMULATION_TIMEOUT_SECS (default 600 s)
returns an error to the user, but the computation currently runs on to
completion in the background, still occupying the CPU. A user who
retries straight away starts a second one beside it. On an undersized
server that is how one slow forecast turns into every forecast timing
out — so size for your largest plan, or raise the timeout, rather than
relying on it to shed load.
On Kubernetes, set a CPU limit on the API. The API
sizes its forecast workers from the CPU it is allowed: with
limits.cpu: 4 it uses four cores. With a CPU
request but no limit, it sees every core on the node and runs
that many workers, contending with everything else scheduled there. Set
limits.cpu to the cores you sized for, and set
limits.memory from the memory rule above — an API that
exceeds it mid-forecast is restarted, and the forecast is lost.
Getting the images
All three images come from a private registry. The same images, with identical digests, are published to two; use the one your access was issued for:
| Setting | GitLab | GitHub |
|---|---|---|
| Registry | registry.gitlab.com |
ghcr.io |
| Path | registry.gitlab.com/tomasz-feliksik/flow-metrics-base |
ghcr.io/lean-agility |
| Username | token (the literal word) |
the GitHub account the access was granted to |
| Password | the access token issued with your license
(read_registry) |
a classic personal access token with
read:packages |
GHCR rejects fine-grained tokens. With the distribution’s scripts,
the three rows map to REGISTRY_URL,
REGISTRY_USER and ACCESS_TOKEN in
.env.
On your own platform the token belongs in the platform’s image-pull
credential store — a Kubernetes imagePullSecret, your
node’s ~/.docker/config.json, your registry mirror’s
upstream credentials — not in an environment file next
to the application. The API and frontend containers never see it; it is
consumed once, at pull time.
PostgreSQL. The distribution mirrors an official
image at ${REGISTRY_URL}/postgres:18.1-alpine so that a
customer with registry access and nothing else can still pull one. If
you have your own PostgreSQL, or your own registry mirror, use it —
Shipcast has no opinion beyond version 13 or newer.
Choosing a tag
<tag> |
You get | Moves when |
|---|---|---|
latest |
the newest release | every release, including features |
1.8 |
the newest patch of 1.8 — fixes, no features | a 1.8.x patch ships |
1.8.0 |
exactly that release, forever | never |
main |
the newest development build (no guarantees) | every change merged upstream |
List what is actually published with
skopeo list-tags docker://$REGISTRY_URL/shipcast/api (the
distribution’s run_shipcast.sh tags does the same thing
through the registry API).
For a managed deployment, pin at least to a minor (1.8),
and prefer an exact version (1.8.0) if you validate
upgrades before they reach users. For a redeploy that is guaranteed
byte-identical, pin the digest instead:
skopeo inspect docker://$REGISTRY_URL/shipcast/api:1.8.0 | grep -E 'Digest|opencontainers'"Digest": "sha256:…"
"org.opencontainers.image.version": "2026-08-11T19-53-52Z-d5f6aa5"
"org.opencontainers.image.revision": "d5f6aa5c9e21…"
Then deploy shipcast/api@sha256:…. Quote the
org.opencontainers.image.version value in any support
request: it identifies the exact build even when you track a moving tag
that has since moved on.
The database
Shipcast owns its schema; your infrastructure owns the
database. The API never issues CREATE DATABASE,
CREATE ROLE, or CREATE EXTENSION. At startup
it checks its preconditions and, if one is unmet, exits with a message
naming the failed precondition and the SQL that fixes it.
| Precondition | Detail |
|---|---|
| PostgreSQL 13 or newer | Tables default their ids to gen_random_uuid(),
core-builtin from 13. Tested against 18. |
| The database already exists | Shipcast will not create it. |
| A role that can authenticate | With CONNECT on that database. |
USAGE + CREATE on public |
Migrations create unqualified objects. See the PostgreSQL 15 note below. |
| Nothing else | No CREATEDB, CREATEROLE, superuser, or
CREATE EXTENSION. Do not hand Shipcast a superuser “to be
safe”. |
Minimal provisioning, as a cluster administrator:
CREATE ROLE shipcast WITH LOGIN PASSWORD 'change-me';
CREATE DATABASE shipcast OWNER shipcast;PostgreSQL 15+. Since version 15, schema
publicno longer grantsCREATEtoPUBLIC. A role that owns the database (as above) has the privilege implicitly. A role that does not — common on shared clusters and some managed providers — needs it explicitly:GRANT USAGE, CREATE ON SCHEMA public TO "shipcast";
Shipcast’s side of the contract: it owns the schema
through forward-only migrations, applied automatically at startup, and
it is the sole writer. Do not hand-edit the schema, and do not share the
database with another application. There is no down
migration — a failed migration is recovered by restoring a backup, not
by rolling back.
The authoritative version of this contract lives in the Shipcast
source repository (docs/database-setup.md,
access provided with your license). It also lists the exact startup
message for each failed precondition.
Connecting: prefer the components over the URL
The API accepts its database configuration two ways, and they are not equivalent:
| Form | Behaviour |
|---|---|
DB_HOST, DB_PORT, DB_USER,
DB_PASSWORD, DB_NAME |
The API builds the URL itself and percent-encodes the user and password |
DATABASE_URL |
Taken verbatim, and wins if both are set |
Use the components. Any password your secret manager
generates works, including one containing @,
/, : or = — characters that
silently corrupt a hand-assembled URL. (This is why the single-machine
.env restricts passwords to
letters, digits, . _ ~ -: the compose file there assembles
DATABASE_URL by hand. On your own platform you have no
reason to inherit that limitation.)
Use DATABASE_URL only when you need connection
parameters the components cannot express — most often TLS to a managed
provider, ?sslmode=require. In that case the password must
be URL-safe again, or percent-encoded by you.
The silent failure. If none of the database variables are set, the API does not fail. It starts in a deliberate limited mode that serves only the stateless Monte Carlo endpoints — no login, no saved simulations. A deployment that mounted its secret under the wrong name therefore comes up green and half-functional rather than crashing. If the startup log does not say
🗄️ Database configured, your database variables did not arrive.
Configuration
Only the database connection is required. Everything else has a working default, and every value the API needs for itself — signing keys, deployment identity — it generates and stores in the database on first boot.
API
| Variable | Required | Default | Purpose |
|---|---|---|---|
DB_HOST … DB_NAME |
yes* | — | Database connection; see above |
DATABASE_URL |
yes* | — | Alternative to the components; wins if both are set |
SHIPCAST_SECRETS_KEY |
no | — | Encrypts tracker credentials at rest; see below |
PORT |
no | 3001 |
Listen port |
RUST_LOG |
no | info |
Log level: error, warn, info,
debug, trace |
LOG_FORMAT |
no | human-readable | Set to json for structured logs on stdout |
ENABLE_SWAGGER |
no | false |
Serves the API schema at /docs, unauthenticated |
SIMULATION_TIMEOUT_SECS |
no | 600 |
Fail a forecast request after this long; see Sizing |
INIT_ADMIN_EMAIL |
no | — | Non-interactive first-admin bootstrap; remove after first boot |
INIT_ADMIN_PASSWORD |
no | — | Non-interactive first-admin bootstrap; remove after first boot |
JWT_TOKEN_EXPIRATION_SECS |
no | 86400 |
Session lifetime (24h) |
JWT_ISSUER |
no | shipcast |
JWT iss claim |
JWT_AUDIENCE |
no | shipcast-api |
JWT aud claim |
* One of the two forms is required. Neither means limited mode — see the silent failure above.
Set LOG_FORMAT=json for anything that ships logs. The
default human-readable format also drops ANSI colour codes automatically
when stdout is not a terminal, so it is readable in a log file, but it
is not machine-parseable.
Frontend
| Variable | Required | Default | Purpose |
|---|---|---|---|
API_URL |
no | http://shipcast-api:3001 |
Where to proxy /api/* |
API_URL may be https:// — the image ships a
CA bundle and verifies the upstream certificate, so the API can sit
behind TLS in another network entirely. No other configuration is
needed: the container resolves its own DNS server at startup and
re-resolves the upstream on every request, so an API container that
restarts with a new IP is picked up without a frontend restart.
The secrets you hold
Three, and they fail in different ways. What matters for each is not how it is stored but what is unrecoverable if it is lost.
| Secret | Protects | If lost |
|---|---|---|
SHIPCAST_SECRETS_KEY |
Tracker (Jira/ADO) credentials | Unrecoverable. Every stored tracker credential must be re-entered by hand |
| Database password | All application data | Resettable by the cluster administrator; no data loss |
| Registry access token | Pulling images | Reissued by your license provider; no data loss |
SHIPCAST_SECRETS_KEY
A base64-encoded 32-byte key, the AES-256-GCM master key for tracker credentials stored in the database:
openssl rand -base64 32Three things to know:
- It is optional. Without it the API starts normally
and everything works except tracker sync: the tracker endpoints answer
503 TRACKER_NOT_CONFIGUREDand the UI shows a “needs configuration” state rather than an error. Set it only if you use tracker integration — but set it before anyone enters credentials, not after. - It must never change. It decrypts the credentials already stored. Rotating it does not re-encrypt them; it orphans them, and every tracker source must be reconfigured.
- Back it up separately from the database. This is
the one that catches people: a
pg_dumptaken without the key restores tracker credentials that nothing can decrypt. The backup looks complete and is not. Store the key wherever your platform keeps secrets, and make sure its retention is at least as long as your database backups’.
If the key is compromised and must be replaced, that is a deliberate procedure, not a config edit — there is no re-encryption path, so plan it rather than discovering it:
- Note which tracker sources exist (Admin → Tracker sources) and which account each one authenticates as. You are about to re-enter all of them.
- Generate a new key, set it, and restart the API.
- Open each tracker source and enter its credential again. Until you do, that source’s sync fails to decrypt.
- Revoke the old Jira/ADO tokens at the tracker. Replacing the encryption key does not invalidate the credentials it was protecting — if they leaked, they are still valid until you revoke them there.
Step 4 is the one that gets forgotten, and it is the only step that actually ends the exposure.
Network, TLS, and the reverse proxy
Shipcast terminates no TLS. The frontend serves
plain HTTP on port 3002. Every network-accessible deployment needs a
reverse proxy in front of it, and the single-machine default binds to
127.0.0.1 precisely so that skipping this step cannot
happen by accident.
The frontend’s own nginx already proxies /api/*
correctly — no path rewriting, response buffering off, a long read
timeout for streamed results. Your proxy must not undo
that:
| Requirement | Why |
|---|---|
Forward /api/* unmodified |
No rewrite, no prefix strip. The frontend routes on that prefix |
| Disable response buffering | Results stream; a buffering proxy makes a working forecast look like a hang, not an error |
| Generous read timeout | A large simulation can run for minutes
(SIMULATION_TIMEOUT_SECS, default 600s) |
| Terminate TLS | Otherwise logins and tracker credentials cross your network in cleartext |
server {
listen 443 ssl;
server_name shipcast.example.com;
ssl_certificate /etc/ssl/certs/shipcast.pem;
ssl_certificate_key /etc/ssl/private/shipcast.key;
location / {
proxy_pass http://127.0.0.1:3456; # the published frontend port
proxy_http_version 1.1;
proxy_buffering off; # streamed results
proxy_read_timeout 1h;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Endpoints that need no authentication
Everything else requires a session, but these are reachable by anyone who can reach the frontend:
| Endpoint | Exposes | Note |
|---|---|---|
GET /api/health |
Version and database reachability | Intended for your health checks |
GET /api/metrics |
Prometheus metrics | Operational data, no credentials |
POST /api/auth/login |
The login endpoint itself | Rate-limit it at your proxy |
GET /api/setup/status |
Whether setup is complete | |
POST /api/setup/complete |
Creates the first admin | Only functional before the first one |
GET /api/license/status |
License state and deployment id | |
PUT /api/license |
Uploads a license | Restrict at the proxy after first use |
GET /docs |
The full API schema | Off unless ENABLE_SWAGGER=true |
This set is a reasonable trade for an internal deployment. For
anything reachable more widely, restrict PUT /api/license
and /api/metrics at the proxy once the deployment is
established.
First boot: admin account and license
Two steps the platform cannot do for you. Both happen after the containers are running, because both depend on state the application generates on its first boot.
1. Create the first administrator
Shipcast starts with no users. On first boot the API logs a
SETUP REQUIRED banner and serves a setup wizard; the web UI
redirects there automatically. Open the deployment and create the
account at /setup.
To do it without a browser — a scripted or unattended deployment — set both of these on the API container for the first boot only:
INIT_ADMIN_EMAIL=admin@example.com
INIT_ADMIN_PASSWORD=…The admin is created at startup. Remove both afterwards; the API logs a security warning while they remain set.
2. Install the license
Without a valid license Shipcast runs in degraded mode: everything loads, but simulation runs are gated off — which for a forecasting product is the product. Tracker integration is gated the same way.
Your license may be bound to this specific deployment, and the deployment’s identity is generated on its first boot — so the order is fixed:
Bring the stack up and let it reach the database.
Read the deployment id:
curl -s http://localhost:3456/api/license/statusSend that id to your license provider.
Install the
.keyfile you receive: sign in as an administrator and upload it at/admin/license, orPUT /api/license.
Check the result at /admin/license. A license reports
Valid, or names its problem: Expired (features
stay available for a 10-day grace period), WrongDeployment,
Invalid, Revoked.
The trap worth knowing before you need it. The deployment id lives in the database. Anything that starts Shipcast from an empty database — a volume wipe, a rebuild “from scratch”, the single-machine
cleancommand — mints a new one, and a deployment-bound license stops validating against it. The stack comes back up looking perfectly healthy, users log in fine, and simulations quietly refuse to run. Restoring a backup preserves the id; starting fresh does not. If you must start fresh, expect to have the license re-issued.
A worked deployment
A compose file you own, with your own database, secrets injected by the platform, and nothing that assumes an interactive terminal. Adapt the shape rather than the syntax — the same four facts (image, port, env, one volume) map directly onto a Kubernetes Deployment pair or two systemd units.
services:
api:
image: registry.gitlab.com/tomasz-feliksik/flow-metrics-base/shipcast/api:1.8.0
restart: unless-stopped
expose: ["3001"]
environment:
DB_HOST: postgres.internal
DB_PORT: "5432"
DB_NAME: shipcast
DB_USER: shipcast
DB_PASSWORD: ${DB_PASSWORD:?} # from your secret store, any characters
SHIPCAST_SECRETS_KEY: ${SECRETS_KEY:?} # omit entirely if you do not use tracker sync
LOG_FORMAT: json
RUST_LOG: info
networks: [internal]
frontend:
image: registry.gitlab.com/tomasz-feliksik/flow-metrics-base/shipcast/frontend:1.8.0
restart: unless-stopped
environment:
API_URL: http://api:3001
ports:
- "127.0.0.1:3456:3002" # your TLS proxy publishes it onward
depends_on: [api]
networks: [internal]
networks:
internal:No database service: it is external, provisioned per The database. No ACCESS_TOKEN: the
host is already authenticated to the registry. No named volume: neither
of these containers stores anything.
Running more than one API replica is supported — the signing keys and the deployment identity are shared through the database, so every replica agrees. Point them all at the same database, and when upgrading let one instance start and finish its migrations before the rest come up.
Operating it
Health and metrics
| Check | Endpoint | Returns |
|---|---|---|
| API liveness | GET /api/health (also /health) |
Version and database_healthy |
| Frontend liveness | GET /health on the frontend container |
200 OK |
| Prometheus scrape | GET /api/metrics (also /metrics) |
Simulation duration, request and run counters |
Both API paths are the same handler, so a probe can use whichever your platform finds easier to configure.
Backups
There are no built-in backup scripts, and there is exactly one thing to back up:
pg_dump --format=custom --file=shipcast-$(date +%F).dump \
"postgresql://shipcast@postgres.internal:5432/shipcast"Restore into a freshly created, empty database of the same name, then start the API against it.
Back up SHIPCAST_SECRETS_KEY alongside, in your secret
store — see The secrets you hold. A
dump without it is not a complete backup.
Upgrading
- Back up first. Migrations are forward-only; there is no down path. Recovery from a bad upgrade is a restore.
- Change the image tag and redeploy. Migrations run automatically at API startup.
- Watch the API log for
Running database migrationsfollowed byDatabase initialized successfully.
Rolling back the image after a migration has run is not supported. Restore the backup and start the previous version against it.
Logs
Both containers log to stdout/stderr, so whatever your platform does
with container output is what happens. Set LOG_FORMAT=json
on the API for structured output and RUST_LOG for the
level.
Recovery: nobody can sign in
The API image carries an admin CLI, reachable in the running container. It reads the same database configuration the server does, so it needs no extra arguments:
docker exec -it <api-container> api admin list # who are the admins?
docker exec <api-container> api admin reset --force # delete ALL adminsAfter a reset, restart the API and visit /setup to
create a new administrator. Note that reset deletes
every admin, deliberately — it returns the deployment to a
clean setup state. User data, simulations and the license are
untouched.
If you have database access but not container access, the equivalent
is DELETE FROM users WHERE is_admin = true; followed by an
API restart.
Verify the deployment
Six checks. Each one fails for a different reason, so the first failure tells you where to look.
| # | Check | Proves |
|---|---|---|
| 1 | The images pull | Registry credentials |
| 2 | GET /api/health reports
database_healthy: true |
The database contract is met |
| 3 | The site loads over HTTPS, through your proxy | Proxy and TLS configuration |
| 4 | You can sign in as the administrator you created | First-boot setup completed |
| 5 | /admin/license reports Valid, and a
simulation runs |
The license is installed and bound |
| 6 | After a full restart, your data and your login both survive | Persistence and a stable identity |
Check 6 is the one worth actually performing rather than assuming: it
is the only one that catches a database that is not really persistent,
and — if you use tracker sync — a SHIPCAST_SECRETS_KEY that
is being regenerated on each deploy instead of held stable.
What Shipcast does not support
Stated plainly, so you do not spend an afternoon discovering it:
- Hosting under a URL sub-path. The frontend serves
the application from the root of its host. Give it a hostname or a
virtual host, not
https://tools.example.com/shipcast/. - Built-in TLS. See Network, TLS, and the reverse proxy.
- Sharing its database with another application. Shipcast is the sole writer of its schema.
- Rolling a migration back. Forward-only; recovery is restore.
- Rotating
SHIPCAST_SECRETS_KEYwithout re-entering every tracker credential.
Support
Contact your license provider for support, access tokens, and license issuance — including a license bound to a new deployment id.