[ 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.
REQUIREDA 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
REQUIREDSomewhere 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 REQUIREDAnything 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 →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 →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 → [ 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.
| Variable | Default | Note |
|---|
| DATABASE_URL | none | The one value with nothing to fall back to. Unset, the service refuses to start rather than guessing at a local socket. |
| DATABASE_POOL_MAX | 10 | Per replica, and worth setting against the server’s own max_connections rather than against one process. |
| INGOT_TEST_DATABASE_URL | unset | The 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.
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
| Variable | Default | Note |
|---|
| INGOT_STORAGE | filesystem | One of filesystem, s3, gcs. Anything else is refused by name. |
| INGOT_DATA_DIR | .ingot-data | The filesystem driver’s root. |
| INGOT_S3_BUCKET | none | Required by s3, with the two keys below. |
| INGOT_S3_ACCESS_KEY_ID | none | Required by s3. |
| INGOT_S3_SECRET_ACCESS_KEY | none | Required by s3. |
| INGOT_S3_ENDPOINT | AWS | A URL for MinIO, R2 or a gateway. Its scheme also decides TLS. |
| INGOT_S3_REGION | us-east-1 | Sent whether or not it means anything to the endpoint. |
| INGOT_S3_PATH_STYLE | on with an endpoint | Explicit wins either way. |
| INGOT_GCS_BUCKET | none | Required by gcs, and the only thing it needs — the credential comes from Application Default Credentials. |
| INGOT_STAGING_DIR | the system temp directory | Scratch 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.
| Variable | Default | Note |
|---|
| INGOT_EMBEDDER | local | One of local, openai, gcp. |
| INGOT_SUMMARISER | local | The same three, chosen independently. |
| OPENAI_API_KEY | none | Required once either selector is openai. |
| OPENAI_BASE_URL | https://api.openai.com/v1 | What makes an Azure deployment or a local vLLM this adapter rather than another one. |
| INGOT_GCP_PROJECT | none | Required by gcp. Not secret — a token does not say whose quota to spend. |
| INGOT_AI_TIMEOUT_MS | 30000 | One 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_CONCURRENCY | 2 | Concurrent embed drains per replica — so what the provider sees is this times the pod count. See below. |
| INGOT_RECEIPTS_CONCURRENCY | 2 | The 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.
config:
background:
embeddings: 2
receipts: 2
deliveries: 6
| Variable | Default | Note |
|---|
| INGOT_EMBEDDINGS_CONCURRENCY | 2 | Concurrent embed drains per replica. Each drain works up to 8 batches of 128 texts. |
| INGOT_RECEIPTS_CONCURRENCY | 2 | Concurrent summariser calls per replica. One LLM call per receipt, so this is spend. |
| INGOT_DELIVERIES_CONCURRENCY | 6 | Concurrent 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.
INGOT_RABBITMQ_URL=
amqps://user:pass@broker:5671
| Variable | Default | Note |
|---|
| INGOT_RABBITMQ_URL | unset | The broker, with its credentials. Unset, { "t": "rmq" } is refused when an ingot is configured for it — webhooks are unaffected. |
| INGOT_RABBITMQ_EXCHANGE | the default exchange | Which routes by queue name, and is what a per-ingot queue name already is. Set it only for your own topology. |
| INGOT_DELIVERY_TIMEOUT_MS | 10000 | A receiver that has not answered by now is not going to. The socket is cancelled, not merely abandoned. |
| INGOT_DELIVERY_ATTEMPTS | 10 | One 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.
| Variable | Default | Note |
|---|
| METRICS_PORT | 9465 | A listener of its own, not a route on the API. |
| OTEL_EXPORTER_OTLP_ENDPOINT | http://localhost:4318 | OTLP/HTTP. Unset it and the service exports nothing. |
What you do not runThe rest of the list
| Not a dependency | What stands in for it |
|---|
| A message broker | The 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 engine | The 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 database | A 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 cluster | DuckDB 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 threeTry 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.