Ingot
API v1Get the sourceAPI docs
[ Deployment ]

Deploy anywhere a container can run

Ingot is one process that needs a Postgres and somewhere to put Parquet.We use Postgres for state and as a message broker - bring an existing instance, or give it a new one.

REQUIRED

A Postgres

The catalogue, the overlay and the queues — plus the coordination, which is the part that is easy to miss.

DATABASE_URL=
  postgres://ingot:ingot
  @localhost:5432/ingot
REQUIRED

Somewhere for Parquet

A bucket or a directory. INGOT_STORAGE names which, and naming it is not optional once a bucket variable is set.

INGOT_STORAGE=s3
INGOT_S3_BUCKET=ingot
INGOT_S3_ENDPOINT=
  http://localhost:9000
NOT REQUIRED

Anything else

No broker, no scheduler, no vector database, no query cluster. What stands in for each of those is described below.

2 containers
1 process
01 · A checkout

On your machine

Bun runs the service, Compose runs the two things it talks to. This is also what the test suite runs against, so what comes up on your machine is what the assertions are made about.

Needs
  • Bun 1.2, and a Docker to hold the two containers
  • Nothing bought — the embedder and the summariser default to offline stand-ins
  • No key, no network, no account: db:up brings up what the defaults already address
Catches people

The cp is the step people skip. DATABASE_URL is the one setting with no default: Ingot refuses to start without a database rather than inventing an address for one, and the message names the variable rather than throwing a connection error at you.

The repository’s own quickstart
Run
# the copy is not optional — DATABASE_URL has no default
bun install
cp apps/ingot/.env.example apps/ingot/.env

bun run db:up     # Postgres and MinIO, waits until both answer
bun run dev       # API on :3002, this site on :5174
Check
curl http://localhost:3002/api/health

# sign up against your own instance
curl -X POST http://localhost:3002/api/v1/accounts \
  -d '{"slug":"acme","name":"Acme Inc"}'

201 Created · the key is shown exactly once
02 · Two images

As a container

The server is one image on ghcr.io, published on every push to main and on every v* tag. It wants a database, somewhere for the Parquet, and one writable mount. None of that is Kubernetes-specific.

Needs
  • A Postgres you brought, reachable from the container
  • A bucket, or a volume, and INGOT_STORAGE naming which
  • One mount over /var/lib/ingot — DuckDB spills a large query there
Catches people

The site is a second image, and its nginx forwards /api/ to INGOT_API_URL. Leave that unset and the dashboard loads, then every call answers 502. Remember the address is resolved from inside the site container, so localhost there is the site itself, not your API.

Both images, and what is in them
Run
# the schema travels in the image, so migrate with the same artefact
docker run --rm -e DATABASE_URL=postgres://… \
  ghcr.io/tjbroodryk/ingot/server \
  bun dist/database/migrate.js

# :3002 is the API, :9465 the metrics listener
docker run -d --name ingot -p 3002:3002 -p 9465:9465 \
  -e DATABASE_URL=postgres://… \
  -e INGOT_STORAGE=s3 -e INGOT_S3_BUCKET=ingot \
  -e INGOT_S3_ACCESS_KEY_ID=… -e INGOT_S3_SECRET_ACCESS_KEY=… \
  -v ingot-scratch:/var/lib/ingot \
  ghcr.io/tjbroodryk/ingot/server
Check
# the image carries its own HEALTHCHECK
docker inspect -f '{{.State.Health.Status}}' ingot

curl http://localhost:3002/api/health

200 OK
03 · A Helm chart

On Kubernetes

One chart holds the server, the site, and a migration that runs as a pre-install,pre-upgrade hook. So the schema is current before a single new pod starts, and a failed migration fails the release rather than a rollout. Autoscaling is on and safe: every sweep takes a Postgres advisory lock, so ten replicas are ten servers and one sweeper.

Needs
  • A cluster, and Helm 3 — the chart is an OCI artifact, so there is no helm repo add
  • A Postgres and a bucket. The chart brings neither, on purpose
  • A Secret, first — the chart refuses to render without one
Catches people

The dashboard reaches the API through the site pod, which forwards /api/ to the release’s server Service. Exposing only the site is enough, whether through an Ingress, a port-forward, or a tailnet. app.apiUrl overrides that address, and the pod resolves it, not the browser, so a hostname only your laptop can see will not work there.

The chart, and what it will not guess
Run
kubectl create namespace ingot

kubectl -n ingot create secret generic ingot-secrets \
  --from-literal=DATABASE_URL='postgres://…' \
  --from-literal=INGOT_S3_ACCESS_KEY_ID='…' \
  --from-literal=INGOT_S3_SECRET_ACCESS_KEY='…'

helm install ingot oci://ghcr.io/tjbroodryk/ingot/charts/ingot \
  -n ingot --set config.s3.bucket=my-ingot-bucket
Check
kubectl -n ingot rollout status deploy/ingot-server

# without an Ingress, borrow the port
kubectl -n ingot port-forward svc/ingot-server 3002:3002
curl http://localhost:3002/api/health
[ Required · postgres:17 ]

The database, and the coordinator

Postgres holds the catalogue and the overlay — never the Parquet. A row is written to the database when it arrives and folded into a Parquet generation later, so what is in Postgres is the rows that have not been folded yet, plus the manifest saying where the folded ones went.

It is also how the replicas agree. The embedding queue, the receipt queue and the delivery outbox are ordinary tables, claimed with FOR UPDATE SKIP LOCKED under a lease, and each sweep that drains one takes a Postgres advisory lock so that exactly one replica is sweeping while the rest serve traffic. Nothing else in the deployment holds a timer, a journal or a lock.

The migrations in apps/ingot/drizzle define the entire schema.

VariableDefaultNote
DATABASE_URLnoneThe one value with nothing to fall back to. Unset, the service refuses to start rather than guessing at a local socket.
DATABASE_POOL_MAX10Per replica, and worth setting against the server’s own max_connections rather than against one process.
INGOT_TEST_DATABASE_URLunsetThe suite’s database, truncated between assertions — so not the one you develop against.
[ Required · the base tier ]

Where the Parquet goes

We support three drivers: filesystem, a local path; s3, meaning AWS and everything that speaks its protocol — MinIO, R2, Ceph; and gcs, held by a service account. filesystem is not a stub. A single node with a volume, or a bucket something else has mounted, is a perfectly good way to run this.

Which one is in use is declared and never inferred. Leave INGOT_STORAGE unset and Parquet goes to the directory in INGOT_DATA_DIR; set a bucket variable and leave the driver unnamed, and the service refuses to boot rather than ignoring it. The failure that rule exists to prevent is a typo in a variable name producing a service that starts, serves traffic and writes every file to a container’s ephemeral disk, where it survives until the next deploy. Naming a driver without its keys is refused the same way, and the message names all of the missing ones at once rather than the first.

# MinIO, as docker-compose.yml runs it
INGOT_STORAGE=s3
INGOT_S3_BUCKET=ingot
INGOT_S3_ACCESS_KEY_ID=ingot
INGOT_S3_SECRET_ACCESS_KEY=ingotingot
INGOT_S3_ENDPOINT=http://localhost:9000
INGOT_S3_REGION=us-east-1
VariableDefaultNote
INGOT_STORAGEfilesystemOne of filesystem, s3, gcs. Anything else is refused by name.
INGOT_DATA_DIR.ingot-dataThe filesystem driver’s root.
INGOT_S3_BUCKETnoneRequired by s3, with the two keys below.
INGOT_S3_ACCESS_KEY_IDnoneRequired by s3.
INGOT_S3_SECRET_ACCESS_KEYnoneRequired by s3.
INGOT_S3_ENDPOINTAWSA URL for MinIO, R2 or a gateway. Its scheme also decides TLS.
INGOT_S3_REGIONus-east-1Sent whether or not it means anything to the endpoint.
INGOT_S3_PATH_STYLEon with an endpointExplicit wins either way.
INGOT_GCS_BUCKETnoneRequired by gcs, and the only thing it needs — the credential comes from Application Default Credentials.
INGOT_STAGING_DIRthe system temp directoryScratch space a roll-up uploads from. Size it for the largest generation a table produces.
[ Optional · embeddings and summaries ]

Expensive bits are opt-in.

Embedding is a per-row cost paid once and a summary is an LLM call paid once per ingot, every time a caller asks for receipt: "full".

They work independantly of each other, but when both enabled, they work well together. Both default to local — deterministic offline stand-ins, so a laptop and the test suite need no network, key or bill. Not appropriate for prod however, so each announces itself at boot.

When using either you must supply the provider credentials and a provider without its credentials refuses to boot.

VariableDefaultNote
INGOT_EMBEDDERlocalOne of local, openai, gcp.
INGOT_SUMMARISERlocalThe same three, chosen independently.
OPENAI_API_KEYnoneRequired once either selector is openai.
OPENAI_BASE_URLhttps://api.openai.com/v1What makes an Azure deployment or a local vLLM this adapter rather than another one.
INGOT_GCP_PROJECTnoneRequired by gcp. Not secret — a token does not say whose quota to spend.
INGOT_AI_TIMEOUT_MS30000One retry on a rate limit happens inside this; a longer wait is left to the next sweep. Capped at 150000, half the lease a claim is held under.
INGOT_EMBEDDINGS_CONCURRENCY2Concurrent embed drains per replica — so what the provider sees is this times the pod count. See below.
INGOT_RECEIPTS_CONCURRENCY2The same, for summariser calls. Each one is a receipt somebody asked for.
[ Optional · what a provider sees ]

Per replica, times the replicas

Every queue here is drained by a bounded number of workers at once, and that bound is the only thing between a burst of writes and an unbounded burst of calls at whatever INGOT_EMBEDDER and INGOT_SUMMARISER name. The work itself is safe at any concurrency — a claim leases its rows, so two drains take different ones — so what these numbers protect is a quota and a bill rather than correctness.

They are per replica, and that is the number to think in. A write wakes the workers in its own process and takes no advisory lock; only a sweep does. So what a provider actually sees is the bound times however many pods are running — and the chart’s autoscaler moves that on CPU, which means a write burst adds pods and multiplies the fan-out precisely when load is highest. At the defaults and maxReplicas: 10, an embedder sees twenty concurrent batches.

ingot_embeddings_pending, ingot_receipts_pending and ingot_deliveries_pending say whether a queue is falling behind. Read them with max() and never sum(): they are read out of Postgres at scrape time, so every replica reports the same shared depth and summing multiplies a backlog by the pod count.

# a quota divided by maxReplicas,
# not a number per pod
config:
  background:
    embeddings: 2
    receipts: 2
    deliveries: 6
VariableDefaultNote
INGOT_EMBEDDINGS_CONCURRENCY2Concurrent embed drains per replica. Each drain works up to 8 batches of 128 texts.
INGOT_RECEIPTS_CONCURRENCY2Concurrent summariser calls per replica. One LLM call per receipt, so this is spend.
INGOT_DELIVERIES_CONCURRENCY6Concurrent deliveries per replica. Higher, because each goes to a different receiver.
[ Optional · webhooks and RabbitMQ ]

Get notified when embeddings and/or receipts are ready

You can either collect a receipt by polling the SELECT and /add call handed back or specify a channel to get a notification pushed to.

So an ingot can nominate a target with POST /:account/:ingot/config, and each receipt is pushed as it lands: a webhook, which needs nothing set here, or an rmq queue, which needs a broker. The split is deliberate. An ingot’s owner chooses where among their own things a receipt goes — an endpoint, a queue name — and the operator chooses what this service will connect to at all. A tenant naming a broker URL would be a tenant choosing where this service opens an authenticated connection.

A queue named on a deployment with no broker is refused at the call that configures it, naming the variable, rather than accepted and then failing every delivery afterwards in a worker log the caller cannot see. Webhook endpoints are checked the same way and at the same moment: absolute http/https, no credentials in the URL, and loopback, link-local and private literals refused — this service would be reaching them from inside its own network, on somebody else’s behalf.

Delivery is at least once via an outbox mechanism. The intention to deliver is a row gets written in the same transaction as the receipt it announces, and a worker sends it afterwards. The aim is to make a receipt impossible to announce and then lose, or lose and never announce. ingot_deliveries_pending says whether a receiver is keeping up; ingot_deliveries_abandoned should sit at zero.

# nothing at all is needed for webhooks
# a broker adds the second transport
INGOT_RABBITMQ_URL=
  amqps://user:pass@broker:5671
VariableDefaultNote
INGOT_RABBITMQ_URLunsetThe broker, with its credentials. Unset, { "t": "rmq" } is refused when an ingot is configured for it — webhooks are unaffected.
INGOT_RABBITMQ_EXCHANGEthe default exchangeWhich routes by queue name, and is what a per-ingot queue name already is. Set it only for your own topology.
INGOT_DELIVERY_TIMEOUT_MS10000A receiver that has not answered by now is not going to. The socket is cancelled, not merely abandoned.
INGOT_DELIVERY_ATTEMPTS10One per minute-long sweep, so roughly ten minutes of somebody else’s outage absorbed without anybody being told.
[ Optional · traces and metrics ]

Metrics Endpoint

OLTP metrics are available on the /metrics endpoint.

VariableDefaultNote
METRICS_PORT9465A listener of its own, not a route on the API.
OTEL_EXPORTER_OTLP_ENDPOINThttp://localhost:4318OTLP/HTTP. Unset it and the service exports nothing.
What you do not run

The rest of the list

Not a dependencyWhat stands in for it
A message brokerThe embedding queue, the receipt queue and the delivery outbox are three Postgres tables. A row is claimed with FOR UPDATE SKIP LOCKED under a lease, so a worker that dies holds its claim until the lease expires rather than stranding the row. RabbitMQ appears above as an optional *output* — somewhere receipts can be delivered onto — and nothing in the service ever reads from it.
A scheduler, or a durable-execution engineThe sweeps run inside the service. The timer is a loop that books the next turn when the last one finishes — a delay between finishes, not a rate that can overlap itself — the retry is an exponential backoff, and one-replica-at-a-time is a Postgres advisory lock. What you give up is a per-step journal: a tick that dies half way starts again from the top. That costs a wasted pass and never a wrong answer, because the queue row is the truth and work that already committed is not claimed twice.
A vector databaseA vector is a column beside the row it belongs to. Ranking happens in the query engine, against the same tables everything else is read from.
A query clusterDuckDB is a library in the process, not a server: a query materialises the tables it names into an in-memory session bounded by INGOT_QUERY_MEMORY_LIMIT and INGOT_MAX_TABLE_ROWS. The one thing it wants from a deployment is INGOT_DUCKDB_EXTENSION_DIR pointed at a directory baked into the image, so a cold container’s first query is not off fetching httpfs from the internet. On a closed network, that is a first query that just fails.
The quickest of the three

Try it yourself

The compose file is the same one the test suite runs against, so what comes up on your machine is what the assertions are made about. That holds whichever of the three you end up deploying.

Run it locallybun run db:up && bun run dev