Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 42 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,20 @@ A GraphQL server that exposes [Mina archive-node](https://docs.minaprotocol.com/
```graphql
query {
events(input: { address: "B62..." }) {
blockInfo { height stateHash timestamp chainStatus }
eventData { data }
transactionInfo { status hash memo }
blockInfo {
height
stateHash
timestamp
chainStatus
}
eventData {
data
}
transactionInfo {
status
hash
memo
}
}
}
```
Expand All @@ -22,11 +33,11 @@ The full surface lives in [`schema.graphql`](./schema.graphql).

Pick the path that matches your situation. Each one is fully covered in [`docs/getting-started.md`](./docs/getting-started.md).

| Path | When to use it |
| --- | --- |
| **[npm](./docs/getting-started.md#path-a--npm-bring-your-own-database)** | You already have an archive-node Postgres reachable. Lightest weight. |
| Path | When to use it |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| **[npm](./docs/getting-started.md#path-a--npm-bring-your-own-database)** | You already have an archive-node Postgres reachable. Lightest weight. |
| **[Prebuilt Docker image](./docs/getting-started.md#path-b--prebuilt-docker-image-bring-your-own-database)** | You have a Postgres reachable but don't want a Node toolchain locally. |
| **[Docker Compose + DB snapshot](./docs/getting-started.md#path-c--docker-compose-with-database-snapshot)** | No archive-node DB available — Compose stands one up from a snapshot. |
| **[Docker Compose + DB snapshot](./docs/getting-started.md#path-c--docker-compose-with-database-snapshot)** | No archive-node DB available — Compose stands one up from a snapshot. |

```sh
# the 30-second taste (Path A)
Expand All @@ -41,31 +52,31 @@ PG_CONN='postgres://postgres:postgres@localhost:5432/archive' \

`PG_CONN` is the only required environment variable. The most common knobs:

| Variable | Default | Description |
| --- | --- | --- |
| `PG_CONN` | *(required)* | Postgres connection string for the archive-node DB |
| `PORT` | `8080` | Port the GraphQL server listens on |
| `ENABLE_GRAPHIQL` | `false` | Serve the GraphiQL playground at `/` |
| `ENABLE_INTROSPECTION` | `false` | Allow GraphQL schema introspection |
| `ENABLE_LOGGING` | `false` | Enable request logging |
| `ENABLE_METRICS` | `false` | Expose Prometheus metrics at `/metrics` |
| `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector |
| `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` |
| Variable | Default | Description |
| ---------------------- | ------------ | -------------------------------------------------- |
| `PG_CONN` | _(required)_ | Postgres connection string for the archive-node DB |
| `PORT` | `8080` | Port the GraphQL server listens on |
| `ENABLE_GRAPHIQL` | `false` | Serve the GraphiQL playground at `/` |
| `ENABLE_INTROSPECTION` | `false` | Allow GraphQL schema introspection |
| `ENABLE_LOGGING` | `false` | Enable request logging |
| `ENABLE_METRICS` | `false` | Expose Prometheus metrics at `/metrics` |
| `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector |
| `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` |

Full reference, including HA / multi-host `PG_CONN` syntax, in [`docs/getting-started.md#configuration`](./docs/getting-started.md#configuration).

## Development

| Command | What it does |
| --- | --- |
| `npm run dev` | Run the server with hot reload (reads `.env`) |
| `npm run build` | Compile TypeScript to `build/` |
| `npm run start` | Run the compiled server |
| `npm run lint` | ESLint over `*.ts` |
| `npm run test:unit` | Unit tests (no DB required) |
| `npm run test` | Full test suite — needs a running [Lightnet](https://docs.minaprotocol.com/zkapps/testing-zkapps-lightnet) |
| `npm run codegen` | Regenerate `src/resolvers-types.ts` from `schema.graphql` |
| `npm run benchmark` | Artillery load test against a running server |
| Command | What it does |
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
| `npm run dev` | Run the server with hot reload (reads `.env`) |
| `npm run build` | Compile TypeScript to `build/` |
| `npm run start` | Run the compiled server |
| `npm run lint` | ESLint over `*.ts` |
| `npm run test:unit` | Unit tests (no DB required) |
| `npm run test` | Full test suite — needs a running [Lightnet](https://docs.minaprotocol.com/zkapps/testing-zkapps-lightnet) |
| `npm run codegen` | Regenerate `src/resolvers-types.ts` from `schema.graphql` |
| `npm run benchmark` | Artillery load test against a running server |

Running the full suite requires Lightnet plus a populated DB:

Expand Down Expand Up @@ -108,6 +119,10 @@ deployment.

For SLOs, capacity guidance, what to monitor, and incident response, see the [operations runbook](./docs/runbook.md).

## Deployment

Reference Kubernetes and production Docker Compose manifests — with liveness/readiness probes, resource limits, autoscaling, and a hardened pod security context — live in [`deploy/`](./deploy/). Read [`docs/security.md`](./docs/security.md) for the deployment contract (TLS gateway, read-only DB role, private Postgres).

## Contributing

- AI coding agents: read [`AGENTS.md`](./AGENTS.md) first.
Expand Down
70 changes: 70 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Reference deployment artifacts

Opinionated starting points for running the Archive Node API in production. They
are references to adapt, not turnkey configs — review image tags, sizing, and
secret management for your environment. Read [`docs/security.md`](../docs/security.md)
first for the deployment contract (TLS gateway, read-only DB role, private
Postgres).

> **These manifests require image `>= 1.0.0`** and are pinned to it. The
> readiness probe and `/metrics` scrape target endpoints that `0.0.x` images do
> not serve — on an older image the readiness probe 404s forever, no pod goes
> Ready, and the Service ends up with no endpoints at all.

## Kubernetes — [`kubernetes.yaml`](./kubernetes.yaml)

A `Deployment` + `Service` + `HorizontalPodAutoscaler` +
`PodDisruptionBudget` with production defaults baked in. Create the Postgres
`Secret` separately so routine `kubectl apply` runs never overwrite a real
connection string with the example placeholder.

- **Liveness** probe on `/healthcheck` (process up) and **readiness** probe on
`/readiness` (database reachable) — a node with a dead DB stops receiving
traffic without being restarted. Readiness tolerates up to 60s of database
slowness before removing endpoints.
- **Resource** requests/limits and a 2 to 6 replica HPA on CPU; the HPA owns the
replica count.
- Hardened pod: non-root, `readOnlyRootFilesystem`,
`allowPrivilegeEscalation: false`, all capabilities dropped, `RuntimeDefault`
seccomp, no mounted service account token, and a scratch `emptyDir` mounted at
`/tmp`.
- Prometheus scrape annotations pointing at `/metrics`, with
`ENABLE_METRICS=true`.
- `preStop` sleeps 15s before SIGTERM, then `terminationGracePeriodSeconds: 45`
leaves room for the app's own 10s `SHUTDOWN_TIMEOUT_MS` drain before SIGKILL.

```sh
# once, with your real connection string (never committed):
kubectl create secret generic archive-node-api \
--from-literal=PG_CONN='postgres://archive_api_ro:...@postgres:5432/archive'

kubectl apply -f deploy/kubernetes.yaml
```

Put a TLS-terminating Ingress/gateway in front and set **`TRUST_PROXY` to the
number of hops** it adds. The gateway must set `X-Forwarded-For`, but the API
ignores that header while `TRUST_PROXY=0` (the safe default for a directly
exposed server), so leaving it unset behind an ingress collapses every client
into a single rate-limit bucket. See [`docs/security.md`](../docs/security.md).

## Docker Compose — [`docker-compose.prod.yml`](./docker-compose.prod.yml)

Runs only the published image against an external Postgres (contrast with the
repo-root `docker-compose.yml`, which is for local dev with a bundled DB).
Compose surfaces the image's `/healthcheck` as container health for images
`>= 1.0.0`; this is a liveness check, not database-aware readiness.

```sh
PG_CONN='postgres://archive_api_ro:...@db:5432/archive' \
docker compose -f deploy/docker-compose.prod.yml up -d
```

## Sizing

The bottleneck is Postgres, not this server. Note that listing several hosts in
`PG_CONN` gives you **failover, not read throughput** — the client sticks to the
first host and only moves on when the connection fails. To spread reads across
replicas, put a load balancer (PgBouncer, HAProxy, a managed reader endpoint) in
front of Postgres and point `PG_CONN` at it. See the benchmark note in the root
[`README.md`](../README.md#hardware-requirements) and use `npm run benchmark` to
size your own deployment.
32 changes: 32 additions & 0 deletions deploy/docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Production-shaped Compose for the Archive Node API alone (bring your own
# Postgres). Unlike the repo-root docker-compose.yml — which stands up Postgres +
# Jaeger for local development — this runs only the published image against an
# external, read-only archive database. See deploy/README.md and docs/security.md.
services:
archive-node-api:
# Pinned, not :latest. Readiness/metrics endpoints require >= 1.0.0.
image: ghcr.io/o1-labs/archive-node-api:1.0.0
restart: unless-stopped
ports:
- '8080:8080'
environment:
# Required. Point at a read-only Postgres role (see docs/security.md).
PG_CONN: ${PG_CONN:?set PG_CONN to your archive-node Postgres connection string}
PORT: '8080'
NODE_OPTIONS: '--max-old-space-size=768'
# Browser clients are cross-origin and CANNOT reach this API unless their
# origin is allowed here — and they fail silently, with nothing in the
# server logs. Set this deliberately (see docs/security.md):
# '*' public API any browser may call
# 'https://app.example.com' known, fixed front-ends (comma-separate)
# Unset means same-origin only, which blocks every browser client.
CORS_ORIGIN: '*'
ENABLE_METRICS: 'true'
# Proxy hops in front of the API. Rate limiting keys on the socket address
# while this is 0, so every client behind a proxy shares one bucket.
TRUST_PROXY: '0'
RATE_LIMIT_MAX: '1200'
# Memory cap (Compose v2). NODE_OPTIONS keeps V8 below this container limit.
mem_limit: 1g
# Images >= 1.0.0 ship a HEALTHCHECK against /healthcheck; Compose surfaces
# it as the container health status.
175 changes: 175 additions & 0 deletions deploy/kubernetes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Reference Kubernetes manifest for the Archive Node API.
#
# This is a starting point, not a turnkey production deploy — review the image
# tag, resource sizing, and secret management for your cluster. Create the
# archive-node-api Secret separately; see deploy/secret.example.yaml and
# deploy/README.md.
#
# REQUIRES image >= 1.0.0. The readiness probe below targets /readiness, which
# earlier images do not serve: on 0.0.x the probe 404s forever, no pod ever goes
# Ready, and the Service is left with zero endpoints — a total outage. The
# /metrics scrape annotations need >= 1.0.0 for the same reason. The image is
# pinned accordingly; do not move it back to :latest.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: archive-node-api
labels:
app: archive-node-api
spec:
selector:
matchLabels:
app: archive-node-api
template:
metadata:
labels:
app: archive-node-api
annotations:
prometheus.io/scrape: 'true'
prometheus.io/port: '8080'
prometheus.io/path: /metrics
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
automountServiceAccountToken: false
containers:
- name: archive-node-api
# Pinned, not :latest — see the version requirement at the top.
image: ghcr.io/o1-labs/archive-node-api:1.0.0
ports:
- containerPort: 8080
env:
- name: PORT
value: '8080'
- name: PG_CONN
valueFrom:
secretKeyRef:
name: archive-node-api
key: PG_CONN
- name: NODE_OPTIONS
# Keep V8 below limits.memory so large responses fail in-process
# before the kernel OOM-kills the pod mid-request.
value: '--max-old-space-size=768'
# Browser clients are cross-origin and CANNOT reach this API unless
# their origin is allowed here — and they fail silently, with nothing
# in the server logs. Set this deliberately (see docs/security.md):
# '*' public API any browser may call
# 'https://app.example.com' known, fixed front-ends (comma-separate)
# Unset means same-origin only, which blocks every browser client.
- name: CORS_ORIGIN
value: '*'
- name: ENABLE_METRICS
value: 'true'
# Must be < terminationGracePeriodSeconds below, so the app finishes
# draining before the kubelet sends SIGKILL.
- name: SHUTDOWN_TIMEOUT_MS
value: '10000'
# Number of proxy hops in front of the API. Rate limiting keys on the
# socket address while this is 0, so every client behind an ingress
# shares one bucket; set it to the real hop count.
- name: TRUST_PROXY
value: '1'
- name: RATE_LIMIT_MAX
# Per client IP per 60s, counted per replica. The HPA moves
# replicas 2 to 6, so the effective global ceiling varies with scale.
value: '1200'
resources:
requests:
cpu: '500m'
memory: '512Mi'
limits:
# No CPU limit: CFS throttling hurts Node.js p99 latency. The CPU
# request above is the scheduling guarantee.
memory: '1Gi'
lifecycle:
preStop:
# Endpoint removal is async and concurrent with SIGTERM. Give
# kube-proxy/ingress time to stop routing here before Node closes.
exec:
command: ['/bin/sh', '-c', 'sleep 15']
# Liveness: process is up. Readiness: the database is reachable.
livenessProbe:
httpGet:
path: /healthcheck
port: 8080
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /readiness
port: 8080
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
successThreshold: 1
startupProbe:
httpGet:
path: /healthcheck
port: 8080
periodSeconds: 3
failureThreshold: 20
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
# 15s preStop + SHUTDOWN_TIMEOUT_MS + margin before SIGKILL.
terminationGracePeriodSeconds: 45
---
apiVersion: v1
kind: Service
metadata:
name: archive-node-api
labels:
app: archive-node-api
spec:
type: ClusterIP
selector:
app: archive-node-api
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: archive-node-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: archive-node-api
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: archive-node-api
spec:
minAvailable: 1
selector:
matchLabels:
app: archive-node-api
8 changes: 8 additions & 0 deletions deploy/secret.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: v1
kind: Secret
metadata:
name: archive-node-api
type: Opaque
stringData:
# Point at a read-only Postgres role (see docs/security.md). Replace before use.
PG_CONN: 'postgres://archive_api_ro:CHANGE_ME@postgres:5432/archive'
Loading