Installation
Sierpe is distributed as a container image and as release binaries. All it needs is an empty Postgres database it can own — Sierpe manages its own schema and migrations.
Choosing an image
| Tag | Contents |
|---|---|
ghcr.io/zkcaleb-dev/sierpe:v1.5.2 |
Slim: static, distroless, multi-arch. Indexes from the RPC and clamps honestly at the retention wall |
ghcr.io/zkcaleb-dev/sierpe:v1.5.2-full |
Slim plus stellar-core, to heal history below RPC retention. linux/amd64 only — see the archive leg |
Start with the slim image. Move to -full when you need history older
than the roughly seven days an RPC serves.
Requirements
- An empty Postgres database reachable via
DATABASE_URL— Sierpe owns the schema and runs its own migrations; do not point it at a database shared with another application. The bundled compose runs Postgres 16. - Outbound HTTPS to public Stellar RPC endpoints.
- Storage grows with the contracts you register, not with the chain: a typical project (a handful of contracts) fits Railway’s smallest paid tier.
Docker Compose
This is the complete file — save it as docker-compose.yml, nothing else
is needed (it matches the one
in the repository):
services:
sierpe:
image: ghcr.io/zkcaleb-dev/sierpe:v1.5.2
# image: ghcr.io/zkcaleb-dev/sierpe:v1.5.2-full # archive leg: heals history below RPC retention (amd64)
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://sierpe:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/sierpe?sslmode=disable
NETWORK: ${NETWORK:-testnet}
ADMIN_TOKEN: ${ADMIN_TOKEN:?set ADMIN_TOKEN (min 16 chars)}
# RPC_URLS: https://your-rpc-1,https://your-rpc-2 # required on mainnet
ports:
- "8080:8080"
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: sierpe
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
POSTGRES_DB: sierpe
volumes:
- sierpe-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sierpe -d sierpe"]
interval: 5s
timeout: 3s
retries: 12
volumes:
sierpe-pgdata:
export POSTGRES_PASSWORD=$(openssl rand -hex 16)
export ADMIN_TOKEN=$(openssl rand -hex 24)
docker compose up -d
curl localhost:8080/health
Set NETWORK=mainnet and RPC_URLS for mainnet.
Railway
One click: Deploy on Railway
creates the Postgres and the Sierpe service already wired together, with
ADMIN_TOKEN and the Basic Auth password generated for your instance.
Open the generated domain, sign in with the HTTP_BASIC_AUTH value from
the Variables tab, paste ADMIN_TOKEN into the UI’s admin box, register
your first contract. Every variable in the template carries a
description of what it does.
The manual route, if you prefer to assemble it yourself — no GitHub account or build step needed:
- New Project → Deploy PostgreSQL. A fresh Railway Postgres is
empty, which is what Sierpe needs; do not reuse a database another app
owns. Note the service name on the card (default
Postgres). - + New → Docker Image, type
ghcr.io/zkcaleb-dev/sierpe:v1.5.2. The first deploy fails until the variables exist — expected. - Variables tab of the new service:
DATABASE_URL=${{Postgres.DATABASE_URL}}— the reference works as-is; nosslmodeparameter is needed on Railway’s internal network.NETWORK=testnet(ormainnet, plusRPC_URLS)ADMIN_TOKEN= a random string, 16+ characters (openssl rand -hex 24)HTTP_BASIC_AUTH=user:password— set it if you will give the service a public domain (next step). Skip it if only other services in the same project will reach it over private networking.
- Settings → Deploy → Healthcheck Path:
/health. Not/ready, which returns 503 while catching up and would fail the deploy. - Settings → Networking → Generate Domain, and when asked for the
port, answer
8080. Sierpe listens on its ownHTTP_PORT(default 8080) and deliberately ignores thePORTRailway injects — honouring it would silently move the listener of every deployment that did not pinHTTP_PORT. Private networking stays on by default: other services in the project reach it athttp://sierpe.railway.internal:8080. - Deploy, then open
https://YOUR-APP.up.railway.app/status. A fresh database starts at the current tip, soreadyflips totruewithin seconds — history arrives per contract, through the backfill.
No volume is needed on the Sierpe service: all state lives in Postgres.
The -full image is the one exception — it wants a few GB of scratch
disk for captive core; see the archive leg.
AWS (ECS Fargate + RDS)
The shape a small team would run: one Fargate task, a managed Postgres, nothing public.
- RDS for PostgreSQL 16, private subnets, not publicly accessible.
Create an empty database and a role that owns it
(
CREATE ROLE sierpe LOGIN PASSWORD '…'; CREATE DATABASE sierpe OWNER sierpe;). Owner is enough — Sierpe runs its own migrations; it never needs superuser. DATABASE_URLmust end in?sslmode=require. RDS enforces TLS by default on PostgreSQL 15+, and the compose file’ssslmode=disableis for the bundled local Postgres only. Userequire, notverify-full: the image carries the public root CAs (it talks HTTPS to the RPC) but not Amazon’s RDS CA, so full verification would fail. This applies to every managed Postgres with a private CA (RDS, Supabase, Neon…).- Task definition: image
ghcr.io/zkcaleb-dev/sierpe:v1.5.2, container port8080,NETWORKas plain environment,DATABASE_URLandADMIN_TOKENas ECSsecretsfrom Secrets Manager. 0.5 vCPU / 1 GiB is a sound start (see sizing below). No volume, no EFS. Use theawslogsdriver; logs are structured JSON with secrets redacted. - Health: the image is distroless (no shell, no curl), so use the
load balancer’s target-group check, not a container
CMDcheck. Path/health, success code 200. Never/readyhere — it returns 503 while catching up, and an ECS health check on it would kill a healthy task mid-backfill. - Networking: private subnets with a NAT gateway — the task needs
outbound HTTPS for the Stellar RPC and for the
ghcr.ioimage pull. An internal ALB gives your backend a stable name inside the VPC. Only if you truly need a public endpoint: internet-facing ALB + ACM certificate and setHTTP_BASIC_AUTH. - Exactly one task:
desiredCount: 1,minimumHealthyPercent: 0,maximumPercent: 100, so a deploy never runs two copies at once (why: next section).
Any container platform
docker run -d -p 8080:8080 \
-e DATABASE_URL=postgres://user:pass@host:5432/sierpe \
-e NETWORK=testnet \
-e ADMIN_TOKEN=$(openssl rand -hex 32) \
ghcr.io/zkcaleb-dev/sierpe:v1.5.2
Operating it on any cloud — the facts that matter
Answers to what an operator (or their assistant) has to decide, stated from the code rather than guessed.
- Run one instance per database in steady state. There is no leader election. A second instance is harmless to the data — the cursor only ever moves forward and every insert is idempotent — but it is pure waste: each copy ingests every ledger (double RPC load) and two backfill workers re-walk each other’s chunks. A brief overlap during a rolling deploy is fine; two replicas as a steady state is not high availability, just double the work. Restarts are safe at any moment: the cursor and the data commit in one transaction, so a killed task resumes exactly where it stopped.
- Shutdown:
SIGTERMis handled; the HTTP server drains for up to 5 seconds and the loop stops between commits. First boot runs the embedded migrations in well under a minute. - Database connections: pgx defaults — at most
max(4, CPUs)pooled connections;pool_max_conns=2in the URL lowers it for tiny plans. PostgreSQL 14 or newer (the driver’s floor); 16 or 17 for a new install. Behind a transaction-mode pooler without prepared-statement support, appenddefault_query_exec_mode=simple_protocol— Sierpe names that fix in its last log line when it dies that way. - Memory: the slim image idles at tens of MB. The ceiling is the backfill, which buffers RPC responses of up to 64 MB each and shrinks its batch when the network is busier than that; plan 512 MB, and 1 GiB if you register many contracts at once.
- TLS to Postgres:
sslmode=requirefor any managed provider with a private CA (RDS, Cloud SQL, Supabase…);verify-fullas-is where the certificate chains to public roots (Azure Flexible Server, Neon, PlanetScale);disableonly for a Postgres on a private network you control. Forverify-fullagainst a private CA, mount the provider’s CA file into the container and addsslrootcert=/path/to/ca.pemto the URL — the driver honours it, no derived image needed. - Testnet resets: when the network is reset (the tip jumps back by millions of ledgers), the loop detects it and stops with zero writes rather than mixing two chains. Recovery is deliberate and manual: drop and recreate the empty database, redeploy, re-register your contracts. Everything Sierpe holds is re-derivable from the chain.
- Defaults you do not need to set: on testnet the RPC pool is
https://soroban-testnet.stellar.org; history archives default to the SDF public archives on both networks. Mainnet has no free public RPC, soRPC_URLSis required there. - Behind a proxy: TLS termination in front is fine. Path prefixes
are not — the embedded UI and the API assume they live at
/. - Health checks inside the container: the image declares
HEALTHCHECK CMD ["/sierpe", "healthcheck"](since 1.5.2), so Docker, Swarm and the self-hosted PaaS family get a health signal despite the distroless base. Kubernetes and cloud load balancers ignore it and probe/healthover the network, which is equally fine. - The
-fullimage runs as root today (its stellar-core base has no unprivileged user). Clusters enforcing the restricted Pod Security profile will reject it; the slim image runs as UID 65532 and passes. See Where it runs for the per-platform picture.
Configuration
Boot configuration comes from environment variables; everything else (contracts, their kinds) is data managed at runtime through the admin API.
| Variable | Required | Meaning |
|---|---|---|
DATABASE_URL |
yes | Postgres connection string; Sierpe owns this database |
NETWORK |
yes | testnet or mainnet |
ADMIN_TOKEN |
yes | Bearer token for the admin surface. At least 16 characters with 6 distinct ones, enforced at boot (openssl rand -hex 24 is fine) |
RPC_URLS |
mainnet | Comma-separated failover pool, in preference order; testnet defaults to the public SDF endpoint |
HTTP_PORT |
no | API port, default 8080 |
START_LEDGER |
no | First ledger for a fresh database (default: current tip) |
HTTP_BASIC_AUTH |
no | user:password; when set, every request needs these credentials except /health and /ready. For public-domain deployments |
STELLAR_CORE_BINARY |
no | Path to a stellar-core binary; enables the archive leg. Pre-set in the -full image |
HISTORY_ARCHIVE_URLS |
no | History archives for the archive leg. Defaults to the SDF public archives |
CAPTIVE_STORAGE_PATH |
no | Disposable scratch space for captive core buckets. Defaults to the OS temp dir |
Secrets are redacted from all logs. Verify the deployment with
GET /health and GET /status; /ready returns 503 while catching up —
wire it to your platform’s readiness probe. Then open / in a browser:
the embedded UI covers the whole surface.
First-run troubleshooting
Every one of these was hit by a real first deployment; the fixes are exact.
| Symptom | Cause | Fix |
|---|---|---|
Boot error naming DATABASE_URL, NETWORK or ADMIN_TOKEN |
Variables are unprefixed — SIERPE_DATABASE_URL is not read |
Use the exact names from the table above |
/ready returns 503, /health returns 200 |
Normal while catching up to the tip | Wait; watch /status — ready flips when the cursor reaches the tip |
401 on POST /v1/contracts |
Missing bearer, or HTTP_BASIC_AUTH is set and the client sent only one credential |
Send Authorization: Bearer $ADMIN_TOKEN; with Basic Auth enabled the admin token is also accepted as the Basic password |
404 contract does not exist on registration |
Contract not found on the configured network — wrong NETWORK, a typo, or an asset whose SAC was never deployed |
Check the id on that network; deploy the SAC first for classic assets |
| A fresh database starts at the tip, not in the past | By design — history arrives via each contract’s backfill, not by replaying the whole chain | Register contracts with "from"; use START_LEDGER only when you need the live cursor itself to begin earlier |
Right after registering, coverage shows indexedFromLedger above indexedToLedger |
An intentionally empty window: the backfill anchors slightly past the live cursor | It closes on its own within a minute; not an error |
Exposing it safely
The default shape is private networking: do not give the instance a
public domain, and let your backend reach it over your platform’s
internal network (on Railway, http://sierpe.railway.internal:8080).
Management surfaces do not face the internet — the same rule of thumb you
apply to RabbitMQ or Postgres.
If you do need a public domain, set HTTP_BASIC_AUTH=user:password, which
gates everything except the orchestrator probes.