Deploy coding.sh serve
Run coding.sh serve with Docker Compose, Fly.io, Railway or Render, or take traces from serverless functions.
| Host | Store | Keys | TLS |
|---|---|---|---|
| Docker Compose | SQLite, Postgres or ClickHouse | made on first start | your proxy |
| Fly.io | SQLite on a volume, or Postgres | made on first start | Fly's |
| Railway | Railway Postgres | yours | Railway's |
| Render | Render Postgres | yours | Render's |
| Serverless | Postgres | yours | the platform's |
Every host follows the same rules:
- Keys. With a volume on
/dataand no keys set, the first start makes a key pair, keeps it on the volume and prints it in the logs. - Keys without a volume. Set
CODING_SH_INGEST_KEYSandCODING_SH_READ_KEYSyourself, or the keys change on every deploy. Make them withserve keygen, as in Self-host for a team. - One writer. Run one instance that ingests. With Postgres, add read-only viewers with
serve --role viewer. - Health.
/healthzanswers while the process is up./readyzanswers 200 once the store is ready and this instance holds the writer lock. - TLS. serve speaks plain HTTP. Behind a proxy or a platform's TLS, set
CODING_SH_TRUST_PROXY=1.
Docker Compose
deploy/compose/ has three stacks. None needs edits; each runs the published image.
curl -fsSLO https://raw.githubusercontent.com/jackulau/coding.sh/main/deploy/compose/compose.yml
docker compose up -d --wait
docker compose logs coding-sh # the keys and the connect command | File | Store |
|---|---|
compose.yml | SQLite on the coding-sh-data volume |
compose.postgres.yml | Postgres 17 in the stack; set POSTGRES_PASSWORD in .env first |
compose.clickhouse.yml | ClickHouse in the stack, blobs on the volume; set CLICKHOUSE_PASSWORD in .env first |
Use another file with docker compose -f compose.postgres.yml up -d --wait. Optional settings go in .env next to the file: your own keys, CODING_SH_PUBLISH=127.0.0.1:4318 to keep the port on loopback behind a proxy, CODING_SH_RETENTION_DAYS, and CODING_SH_IMAGE to pin a version.
The walkthrough, with keys per person, TLS with Caddy and backups, is Self-host for a team.
The test stack: serve, Postgres and S3 blobs
docker-compose.yml at the repo root is the stack the Postgres and S3 store tests use. It runs serve, Postgres and an S3-compatible blob store (RustFS), with every port on 127.0.0.1 and keys for fixed test projects. From a checkout:
deploy/compose/gen-env.sh # writes .env: random secrets, mode 600, prints no secret
docker compose up -d --build --wait
set -a; . ./.env; set +a
curl -s http://127.0.0.1:4318/readyz # {"ok":true,"store":"postgres",…} .envholdsPOSTGRES_PASSWORD,MINIO_ROOT_USERandMINIO_ROOT_PASSWORD(the blob store's credentials),INGEST_KEY_DEMO,INGEST_KEY_FLOODandREAD_KEY.- Running
gen-env.shagain keeps the file, because Postgres takes its password only on first start. To start over:docker compose down -v && deploy/compose/gen-env.sh --force. deploy/compose/config.tomlis mounted as the server config. Add[prices."<model>"]tables there to override built-in prices.docker compose stopsends SIGTERM; serve drains its queue for up to 25 s.
Fly.io
deploy/fly.toml runs one machine with SQLite on a volume. From a checkout, after fly auth login:
fly launch --no-deploy --copy-config --config deploy/fly.toml --name my-coding-sh
fly volumes create coding_sh_data --size 3 --config deploy/fly.toml
fly deploy --config deploy/fly.toml --image ghcr.io/jackulau/coding.sh:latest
fly logs --config deploy/fly.toml --no-tail | grep -A5 'No keys are configured' The logs print the first-start keys. Use https://my-coding-sh.fly.dev in place of the URLs they show, then connect with coding.sh init --remote https://my-coding-sh.fly.dev --key csh_ik_….
To move to Postgres, set one secret; blobs follow to the same database:
fly secrets set --config deploy/fly.toml CODING_SH_STORE_URL='postgres://…?sslmode=require' Fly.io details
- One machine. SQLite on a volume means exactly one machine:
fly scale count 1. - Your own keys.
fly secrets set --config deploy/fly.toml CODING_SH_INGEST_KEYS='myapp:sha256:…' CODING_SH_READ_KEYS='*:sha256:…'. Set them before you remove[mounts]fromfly.toml: without the volume, first-start keys change on every deploy. - Always on.
auto_stop_machines = "off", because a stopped machine would drop exporter batches. - Deploys. Fly replaces the machine in place: SIGTERM, a drain of up to 25 s (
kill_timeout = 30), then the new version. Clients retry during the gap. The check is/readyz. - Blue-green deploys. With the
bluegreenstrategy, change the check to/healthz: the new machine waits in standby until the old one releases the writer lock. - TLS is Fly's.
CODING_SH_TRUST_PROXY=1is set infly.toml.
Railway
deploy/railway.json builds the repo's Dockerfile and checks /healthz. The store is a Railway Postgres database in the same project. In the dashboard:
- Create a project with *Deploy from GitHub repo* (your fork of coding.sh). Add *+ New → Database → PostgreSQL*.
- In the coding.sh service, set *Settings → Config-as-code* to
/deploy/railway.json, and add these variables:
``sh CODING_SH_STORE_URL=${{Postgres.DATABASE_URL}} CODING_SH_INGEST_KEYS=myapp:sha256:… CODING_SH_READ_KEYS=*:sha256:… CODING_SH_TRUST_PROXY=1 ``
- Generate a public domain under *Settings → Networking*, deploy, and check
https://<your-domain>/readyzfor{"ok":true,"store":"postgres",…}.
Railway details
- No build. *+ New → Docker Image* with
ghcr.io/jackulau/coding.sh:latestskips the build. Add a volume at/dataand leaveCODING_SH_STORE_URLunset to run on SQLite; serve then makes its keys on first start and prints them in the deploy logs. - Why
/healthz. Railway starts the new deployment while the old one still holds the writer lock, so the new one answers/readyzwith 503 until the old one stops. - Handover.
drainingSeconds: 30covers the 25 s queue drain, and clients retry in between. - Port. Railway sets
PORT; serve listens on it. - One replica. Only one replica ingests; more add nothing.
Render
render.yaml at the repo root is a Blueprint: a Docker web service plus a Render Postgres database whose connection string becomes the store URL.
- Make an ingest key and a read key with
serve keygen. Render services have no volume here, so serve cannot keep first-start keys. - Open
https://render.com/deploy?repo=<your fork's URL>, or pick *New → Blueprint* in the dashboard. Render asks once forCODING_SH_INGEST_KEYSandCODING_SH_READ_KEYS. - Check
https://<service>.onrender.com/readyzfor{"ok":true,"store":"postgres",…}.
Render details
- Plans. The Blueprint uses
0.5c-512mbfor the service and0.1c-256mbfor the database. The free web plan sleeps when idle and would drop exporter batches. - Why
/healthz. Render starts the new instance while the old one holds the writer lock, so the Blueprint checks/healthz. Clients retry the 503s during the handover. - Port. Render sets
PORT; serve listens on it. - Retention. A small database fills up. Add
CODING_SH_RETENTION_DAYS=30to the service.
Serverless
Apps on Vercel, Cloudflare or any serverless platform send to a coding.sh server like any other app. A function can freeze as soon as it responds, so flush the SDK before it does:
import { waitUntil } from "@vercel/functions";
import { flush, observe } from "@codingdotsh/sdk";
const openai = observe(new OpenAI());
export async function POST(req: Request) {
const res = await openai.chat.completions.create({ /* … */ });
waitUntil(flush());
return Response.json(res);
} The ingest routes can also run as a function, with createFetchHandler() from coding.sh/server and a Postgres store:
npm install coding.sh coding.sh-store-postgres // app/api/coding-sh/[...path]/route.ts
import { waitUntil } from "@vercel/functions";
import { createFetchHandler } from "coding.sh/server";
const handler = createFetchHandler({ basePath: "/api/coding-sh", waitUntil });
export { handler as GET, handler as POST, handler as OPTIONS }; Senders then use https://app.example.com/api/coding-sh as their endpoint. The function takes /v1/traces, /v1/logs, /v1/metrics, /v1/ingest, /healthz and /readyz. It runs the same code as serve: auth, limits, redaction, the cost estimate and the write. The viewer, the read API, /metrics and retention still need a coding.sh serve --role viewer on the same database.
Serverless details
- Config comes from the environment, as for serve:
DATABASE_URLorCODING_SH_STORE_URL,CODING_SH_BLOB_URL,CODING_SH_INGEST_KEYSandCODING_SH_CAPTURE_MODE. Passconfigorstoresto set them in code. - Keys. Every request counts as remote. With no ingest keys, every ingest request gets 401 and
/readyzanswers 503. - Other runtimes. On Cloudflare Workers with
nodejs_compat,export default { fetch: handler }. With Deno or Bun,Deno.serve(handler), thenawait handler.close()on shutdown. These use the same code throughnode:built-ins but are not tested here. - Several instances.
coding.sh-store-postgreslocks each session while it rebuilds its stats, so concurrent writers end with the same stats as serial ones. A transaction-mode pooler works. - SQLite and memory stores have no such lock: use one instance with them.
- Limits. Spans of one trace that land on different instances can map less well than on one serve. Each instance opens its own database connection, so use a pooler.
- AI Gateway. Point the Vercel AI Gateway trace drain at
https://<your server>/v1/traceswith the ingest key as a Bearer header. No code in your app.
Next steps
- Self-host for a team: keys per person, TLS and backups.
- Trace an app in production: send traces from your app.
- Postgres and ClickHouse stores: store URLs, the tables and SQL.
- Configuration: every
[serve]key and environment variable.