Deployment
Run Firn locally with Docker Compose or deploy to production as a standalone container.
Local development
The Docker Compose file launches MinIO (local S3) alongside the published Firn API image. No host Rust toolchain or local compilation is required.
git clone https://github.com/gordonmurray/firnflow
cd firnflow
docker compose up -d
Check readiness with curl http://localhost:3000/health. To build the image from the checkout instead, run docker build -t firnflow:local . and start with FIRNFLOW_IMAGE=firnflow:local docker compose up -d.
This starts three services:
| Service | Port | Purpose |
|---|---|---|
minio | 9000 (S3 API), 9001 (console) | S3-compatible object storage |
minio-init | - | One-shot: creates the firnflow bucket |
firnflow | 3000 | Firn API server |
MinIO console: http://localhost:9001 (credentials: minioadmin / minioadmin)
Running just MinIO
If you want to run Firn outside Docker (e.g. during development with a host Rust toolchain), start only MinIO:
# Start MinIO only
docker compose up -d minio minio-init
# Run Firn directly
FIRNFLOW_STORAGE_URI=s3://firnflow \
FIRNFLOW_S3_ENDPOINT=http://127.0.0.1:9000 \
FIRNFLOW_S3_ACCESS_KEY=minioadmin \
FIRNFLOW_S3_SECRET_KEY=minioadmin \
cargo run -p firnflow-api
Production Docker
For production, use the published image or build the included multi-stage Dockerfile yourself.
Published image
docker pull ghcr.io/gordonmurray/firnflow:0.9.5
Build the image
docker build -t firnflow-api .
Image details
| Stage | Base image | Purpose |
|---|---|---|
| Builder | rust:1.94-bookworm | Compiles the release binary with protobuf support |
| Runtime | debian:bookworm-slim | Minimal image with just ca-certificates for TLS |
Run the container
docker run -d \
--name firn \
-p 3000:3000 \
-e FIRNFLOW_STORAGE_URI=s3://my-production-bucket \
-e FIRNFLOW_S3_REGION=eu-west-1 \
-e FIRNFLOW_CACHE_MEMORY_BYTES=268435456 \
-e FIRNFLOW_CACHE_NVME_BYTES=10737418240 \
-v firn-cache:/var/lib/firnflow/cache \
ghcr.io/gordonmurray/firnflow:0.9.5
If you built the image locally with the preceding command, replace the final image name with firnflow-api.
For native GCS, swap the URI scheme and mount a service-account JSON: -e FIRNFLOW_STORAGE_URI=gs://my-production-bucket -e GOOGLE_APPLICATION_CREDENTIALS=/etc/gcp-sa.json -v /etc/gcp-sa.json:/etc/gcp-sa.json:ro. Everything else stays the same.
/var/lib/firnflow/cache for the NVMe tier, which is where the container image points FIRNFLOW_CACHE_NVME_PATH. Note that this overrides the /tmp/firnflow-cache default the server itself uses, so the path to mount comes from the image, not from the binary's default. The cache is ephemeral: losing it only means cache misses until it warms up again. For best performance, use a tmpfs or NVMe-backed volume.
Writable directories
Firn writes to two local directories, and both must be writable by the user the server runs as. Neither is optional in the sense that matters here: the server refuses to start if it cannot write to the one that is enabled.
| Variable | Default | Image default | Needed when |
|---|---|---|---|
FIRNFLOW_CACHE_NVME_PATH |
/tmp/firnflow-cache |
/var/lib/firnflow/cache |
Always. This is the NVMe tier of the result cache and it is built during startup. |
FIRNFLOW_OBJECT_CACHE_DIR |
/tmp/firnflow-object-cache |
unset, so the default applies | Only when FIRNFLOW_OBJECT_CACHE_ENABLED=true. |
Under a restricted Pod Security Standard, or any container run with readOnlyRootFilesystem: true, mount a writable volume at each path above that applies to your configuration. Mounting only the object cache directory is the easy mistake, because it is the one the configuration reference discusses at length, while the NVMe result cache is enabled by default and needs a volume whether or not you have configured anything.
The image pre-creates /var/lib/firnflow/cache, so directory creation succeeds and the failure comes later, when the cache opens its block file inside it. That startup error now names both the directory and the variable that selected it; on older releases it reported only Read-only file system (os error 30), with nothing to search for.
volumeMounts:
- name: firn-cache
mountPath: /var/lib/firnflow/cache
volumes:
- name: firn-cache
emptyDir: {}
AWS deployment
For production on AWS, Firn works best with IAM-based credentials (instance profiles, ECS task roles, or IRSA for EKS). No access keys needed.
Prerequisites
- An S3 bucket with public access blocked
- An IAM role with
s3:GetObject,s3:PutObject,s3:DeleteObject,s3:ListBucketon the bucket - A compute environment (ECS, EKS, EC2) with the IAM role attached
Minimal IAM policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-firn-bucket",
"arn:aws:s3:::my-firn-bucket/*"
]
}
]
}
ECS task definition (excerpt)
{
"containerDefinitions": [
{
"name": "firn",
"image": "firnflow-api:latest",
"portMappings": [{"containerPort": 3000}],
"environment": [
{"name": "FIRNFLOW_STORAGE_URI", "value": "s3://my-firn-bucket"},
{"name": "FIRNFLOW_S3_REGION", "value": "eu-west-1"},
{"name": "FIRNFLOW_CACHE_MEMORY_BYTES", "value": "268435456"},
{"name": "FIRNFLOW_CACHE_NVME_BYTES", "value": "10737418240"}
],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
"interval": 10,
"timeout": 3,
"retries": 3
},
"mountPoints": [
{
"sourceVolume": "cache",
"containerPath": "/var/lib/firnflow/cache"
}
]
}
]
}
Authentication and rate limiting
The API ships with optional bearer-token authentication. Production deployments should always set FIRNFLOW_API_KEY. Running without it triggers a startup WARN log line and is unsupported. See configuration for the full env-var list.
FIRNFLOW_API_KEY: read/write tier. Required.FIRNFLOW_ADMIN_API_KEY: separate destructive-ops key. Optional but recommended; set to a different value to lockdelete/compact/ index builds behind a credential the application clients do not hold.FIRNFLOW_METRICS_TOKEN: optional gate on/metrics. Use the same Bearer header.FIRNFLOW_RATE_LIMIT_RPS+FIRNFLOW_RATE_LIMIT_BURST: per-principal token-bucket limiter. Set once a normal traffic baseline is visible in/metrics.FIRNFLOW_TRUST_PROXY_HEADERS=true: only when Firn sits behind a load balancer or reverse proxy that strips client-suppliedX-Forwarded-Forat the edge.
Recommended rollout: set FIRNFLOW_API_KEY before exposing the service, roll clients to send Authorization: Bearer …, then set FIRNFLOW_ADMIN_API_KEY to a distinct value, and finally tune the rate-limit knobs. Rollback is to unset the variables and restart. No on-disk schema or cache format change is involved.
# curl with bearer header
curl -X POST https://firn.example.com/ns/production/upsert \
-H "Authorization: Bearer $FIRNFLOW_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"rows": [...]}'
Health checks
Use GET /health for both liveness and readiness probes. The endpoint returns 200 with body ok as soon as the server is listening.
Docker Compose
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
interval: 10s
timeout: 3s
retries: 3
Kubernetes
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
Cache warmup after deployment
After a fresh deployment or restart, the cache is empty. Use the /ns/{ns}/warmup endpoint to pre-populate it with your most common queries:
curl -X POST http://localhost:3000/ns/production/warmup \
-H 'Content-Type: application/json' \
-d '{
"queries": [
{"vector": [1.0, 0.0, ...], "k": 10},
{"vector": [0.0, 1.0, ...], "k": 10}
]
}'
This returns immediately (202) and runs the queries in the background, populating the exact cache as it goes. If your production traffic uses semantic caching, include the same semantic_cache block on eligible single-vector warmup queries so the in-memory semantic sidecar is seeded too. Monitor firnflow_cache_misses_total to track warmup progress and firnflow_semantic_cache_hits_total / firnflow_semantic_cache_rejections_total to confirm the semantic layer is behaving as expected.
Recommended operational setup
- Reverse proxy: put nginx, Caddy, or an ALB in front of Firn for TLS termination and rate limiting
- Prometheus scraping: scrape
/metricsat 15s intervals - Log aggregation: Firn logs structured JSON via
tracing. Ship to your log aggregator. - Cache volume: use a fast local SSD or NVMe, not networked storage
- Compaction schedule: run
/ns/{ns}/compactperiodically for write-heavy namespaces