Skip to main content

Deploy: One Node and PostgreSQL

Run the published Customertimes gateway image on one Linux machine against PostgreSQL, from pulling the image to the first admin sign-in.

This page takes one Linux machine with Docker from nothing to a running, verified gateway. It is the same procedure the Customertimes-published bundle in deploy/self-host/ of the repository automates, and the three-node layout grows from it without redoing any step. On Oracle Cloud, read Deploy on Oracle Cloud alongside this page for the OCI resources each step maps to.

The version in every command below is the one this gateway runs (0.44.0); the page is rendered from the same build, so it cannot name a release the gateway is not on.

What you need

Item Requirement
Machine Linux (amd64 or arm64), Docker Engine 24+ with Compose v2, 2 vCPU and 4 GB RAM minimum, outbound HTTPS
Image access a GitHub token with read:packages, issued by Customertimes. The image is private
PostgreSQL version 16 or newer, reachable from the machine on 5432 with TLS. The vector, uuid-ossp and pgcrypto extensions must be available. The bundled option runs pgvector/pgvector:pg18 beside the app
Model provider an Anthropic API key (OpenAI or Gemini also work; claude-* models route to Anthropic)
Public hostname an HTTPS address with TLS terminated by nginx on the machine or by a load balancer in front of it. HTTPS is mandatory: the sign-in cookie is Secure and passkeys require a secure context

1. Get the bundle and the image

git clone https://github.com/systempromptio/systemprompt-customertimes.git
cd systemprompt-customertimes/deploy/self-host
echo "$GHCR_TOKEN" | docker login ghcr.io -u <github-user> --password-stdin

The image is ghcr.io/systempromptio/systemprompt-customertimes:<version>. Always pin a version; latest follows the main branch.

Images built by the release pipeline are cosign-signed, and this verifies one:

cosign verify \
  --certificate-identity-regexp='https://github.com/systempromptio/systemprompt-customertimes/' \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  ghcr.io/systempromptio/systemprompt-customertimes:0.44.0

2. Provision the database

Skip this step if you use the bundled PostgreSQL: its first start installs the extensions itself.

For a database you manage, run provision.sql once as the administrative user. It creates the systemprompt role and database and installs the three extensions, which need rights the application role never gets:

psql "postgres://<admin>:<pw>@<db-host>:5432/postgres?sslmode=require" \
  -v pw="<application-db-password>" -f provision.sql

The application connection string is then:

postgres://systemprompt:<application-db-password>@<db-host>:5432/systemprompt?sslmode=require

Each node opens up to 50 connections by default. Migrations run at boot and are forward-only, so take a backup before every upgrade.

3. Render the profile and secrets

Run once. The result is a directory with profile.yaml and secrets.json; the second file holds the signing key, so keep it in your vault. The profile holds only what differs per deployment (about a hundred lines); the provider catalog and gateway routes ship inside the image, and the template's first line pins it to the image release, which --image and preflight.sh check.

./render-profile.sh --out ./profile \
  --external-url https://ai.example.com \
  --admin-email you@example.com \
  --trusted-proxies 127.0.0.0/8 \
  --database-url 'postgres://systemprompt:<pw>@<db-host>:5432/systemprompt?sslmode=require' \
  --anthropic-key sk-ant-... \
  --image ghcr.io/systempromptio/systemprompt-customertimes:0.44.0
Flag Value
--external-url the HTTPS address users open. Every host-dependent setting is derived from it: the JWT issuer, the CORS origin, the URLs the instance advertises for itself, and the OAuth callback URLs
--admin-email the first admin account, created at boot as user admin
--trusted-proxies the address your TLS proxy or load balancer connects from. nginx on the same machine: 127.0.0.0/8. Without it forwarded headers are ignored and rate limiting can refuse callers
--database-url from step 2, or postgres://systemprompt:<pw>@postgres:5432/systemprompt for the bundled database
--image optional; validates the rendered profile inside the image before you start anything

Settle the hostname before the first boot. Changing --external-url later is not a re-render and a restart: the JWT issuer moves with it, so every issued session and bridge token stops validating, and passkeys are bound to the origin they were registered on, so those users enrol again.

render-profile.sh also generates the three secrets that form the instance's identity: the OAuth pepper, the manifest signing seed, and an RSA signing key. Re-running it keeps them; it only rewrites what you pass. SECRETS.md in the bundle lists every key, its owner, and what breaks without it.

4. Start

export SYSTEMPROMPT_TAG=0.44.0 POSTGRES_PASSWORD='<pw>'
docker compose -f docker-compose.single.yml pull
docker compose -f docker-compose.single.yml up -d        # own database: append `app`
docker compose -f docker-compose.single.yml ps           # wait for app: healthy

First boot waits for PostgreSQL, applies the schema, creates the admin user, and renders the public site. The container runs as uid 1000 and mounts the profile directory read-only.

Releases before the per-node render landed in the entrypoint skipped this step on every node but the first. If verify-node.sh reports the public site missing, render it by hand once the node is healthy:

docker compose -f docker-compose.single.yml exec \
  -e SYSTEMPROMPT_PROFILE=/app/.systemprompt/profiles/self-host/profile.yaml \
  app systemprompt infra jobs run publish_pipeline

5. Put TLS in front

The node listens on port 8080 over HTTP. Terminate TLS in front of it and forward these headers; the full nginx block is in Expose Your Instance Remotely:

Rule Value
Upstream http://127.0.0.1:8080
Headers Host, X-Forwarded-For, X-Forwarded-Proto: https
Streaming response buffering off, read timeout 300 s on /v1/ and /api/
Health check GET /api/v1/health, HTTP 200 and body containing "healthy". During boot the same path answers {"status":"starting"}
Block externally /metrics and /api/v1/health/detail
Firewall 8080 from the proxy only; 5000–5999 and 9000–9999 are internal servers and must not be reachable from outside the machine

6. Verify and sign in

./verify-node.sh --url http://localhost:8080 \
  --container "$(docker compose -f docker-compose.single.yml ps -q app)" --version 0.44.0

The checklist covers health, the admin login page, the public site, every client download against its checksum, anonymous rejection on /v1/messages, the binary version, both MCP servers, the admin_console agent, the migration ledger and the admin user. All checks must pass before you go on.

Sign in at https://ai.example.com/admin/login with the admin e-mail, register a passkey, and hand users the Downloads page, which your own gateway serves.

Upgrade and roll back

export SYSTEMPROMPT_TAG=<new-version>
docker compose -f docker-compose.single.yml pull && docker compose -f docker-compose.single.yml up -d
./verify-node.sh --version <new-version> ...

Versioned tags never move. Rolling back to the previous tag is the same command and is safe only while the database has not been migrated forward; otherwise restore the backup taken before the upgrade.

Change configuration without rebuilding

The image is rebuilt once per release. Configuration should not wait for one.

services/ is copied into the image at build time (COPY services /app/services) and read once at boot, so a running node serves whatever it was built with. To change it between releases, bind-mount your own copy over the baked-in tree and restart. SYSTEMPROMPT_SERVICES_PATH is already /app/services in the image, so the mount is all that is needed.

Mount the whole tree:

services:
  app:
    volumes:
      - ./services:/app/services

Or a single file, leaving the rest of the shipped tree in place:

      - ./services/ai/gateway.yaml:/app/services/ai/gateway.yaml

Then docker compose up -d app. A restart is required — the services tree is parsed once during boot, so an edit to a mounted file does nothing until the container restarts.

Copy the shipped tree out first so you are editing what the node actually runs, rather than a tree that has drifted from the release:

docker compose cp app:/app/services ./services

Two things to know before you mount

Without a mount, edits made through the admin UI do not survive. The admin gateway editor writes /app/services/ai/gateway.yaml inside the container. That is a real file and the change takes effect, but it lives in the container's writable layer — replacing the container on the next up -d or upgrade silently restores the baked-in file and the edit is gone, with nothing reported. If you administer routes through the UI, mount the tree.

Do not mount read-only if you use the admin UI. A :ro mount is the safer choice for a tree you manage in git and deploy from — nothing in the container can change it, and what you committed is what runs. But the gateway editor then fails on write. Choose one: git-managed and read-only, or UI-managed and writable. Mounting read-only while expecting the UI to work is the one combination that produces a confusing failure.

A malformed or missing file in a mounted tree fails the boot loudly, naming the file it could not load — it does not start with the configuration silently absent.

Growing to more nodes

Copy the profile directory to each new machine unchanged, point every node at the same PostgreSQL primary, and boot the nodes one at a time. The bundle's README.md covers the three-node layout, load balancers per region, and the rules that apply once more than one node shares a database.