Flow Metrics

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

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:

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 public no longer grants CREATE to PUBLIC. 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 32

Three things to know:

  1. It is optional. Without it the API starts normally and everything works except tracker sync: the tracker endpoints answer 503 TRACKER_NOT_CONFIGURED and 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.
  2. 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.
  3. Back it up separately from the database. This is the one that catches people: a pg_dump taken 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:

  1. Note which tracker sources exist (Admin → Tracker sources) and which account each one authenticates as. You are about to re-enter all of them.
  2. Generate a new key, set it, and restart the API.
  3. Open each tracker source and enter its credential again. Until you do, that source’s sync fails to decrypt.
  4. 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:

  1. Bring the stack up and let it reach the database.

  2. Read the deployment id:

    curl -s http://localhost:3456/api/license/status
  3. Send that id to your license provider.

  4. Install the .key file you receive: sign in as an administrator and upload it at /admin/license, or PUT /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 clean command — 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

  1. Back up first. Migrations are forward-only; there is no down path. Recovery from a bad upgrade is a restore.
  2. Change the image tag and redeploy. Migrations run automatically at API startup.
  3. Watch the API log for Running database migrations followed by Database 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 admins

After 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:


Support

Contact your license provider for support, access tokens, and license issuance — including a license bound to a new deployment id.