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.
On this page
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.