Quickstart
Bring up a local cluster, create a bucket and a stream, then append, read, and tail it from the shell, over HTTP, and from Go
This page takes you from nothing to a running three-node cluster and a
stream you append to, read back, and follow live: first with
nova, then with curl against the HTTP surface, then from the Go SDK.
Every command runs against the local compose stack.
What you need
- Docker with Compose
- Go 1.26 or later, needed only for the CLI install and the SDK section
Bring up the cluster
The stack runs prebuilt images, so one remote file is enough; you do not need a checkout or a build:
curl -fsSL https://raw.githubusercontent.com/supabase/nova/main/deploy/docker/docker-compose.quickstart.yml -o nova-compose.yml
docker compose -f nova-compose.yml up -dYou get three running novad instances (three stars, one per
availability zone) plus the metastore, object storage, and the Gateway:
| Service | URL | Notes |
|---|---|---|
| MinIO console | http://localhost:9201 | nova / novatest |
| novad-1/2/3 gRPC data plane | localhost:7781 / 7782 / 7783 | what nova and the Go SDK dial |
| novad-1/2/3 HTTP data bridge | localhost:7791 / 7792 / 7793 | the same API as JSON, for callers inside the trust domain |
| Gateway | http://localhost:8080 | the internet-facing HTTP front door |
| novad-1/2/3 metrics and pprof | localhost:9191 / 9192 / 9193 | /metrics, /healthz, /readyz, /debug/pprof/ |
| Admin dashboard | http://localhost:9191/admin/ | any node shows the whole cluster; read-only |
The full observable stack (Grafana dashboards, Prometheus, the object-cache tier) builds from source in the repo checkout; see Docker compose.
Any node serves any stream's history. The cluster redirects producers and live-tail
reads to the stream's leader, at the leader's advertised address,
host.docker.internal:<port>. If that name does not
resolve on your host (some Linux setups), add
127.0.0.1 host.docker.internal to /etc/hosts.
Install the nova CLI
go install github.com/supabase/nova/cmd/nova@latest
export NOVA_ADDR=localhost:7781NOVA_ADDR is the --addr flag's environment form; any of the three
nodes works.
Create a bucket and a stream
Every stream lives in exactly one bucket, and buckets are never implicit, so create one first:
nova bucket create demo
nova stream create --bucket demo --key eventsstream create prints the stream's address, nova://demo/events, and the
configuration the bucket's defaults gave it (class, producer
policy, retention, throughput). nova://bucket/key is the address form
every other nova verb takes.
Append records
nova append reads stdin and appends one record per line:
printf 'hello\nworld\n' | nova append nova://demo/eventsappended 2 entriesThe first record gets sequence number 0, the second 1. Sequence
numbers are contiguous and never change.
Read them back
nova read nova://demo/events --from 0Each line is seq_num, the record's timestamp, and its body:
0 2026-08-24T10:15:02Z hello
1 2026-08-24T10:15:02Z world--from defaults to the earliest record still retained; --since takes an
RFC 3339 time instead; --limit stops after N records (default 100).
Check the tail
nova check-tail nova://demo/events2 2026-08-24T10:15:02ZThe tail is one past the last committed record: the sequence number the next append will receive, paired with the last record's time. The stream's leader serves it strongly consistently.
Follow the stream live
tail shows the last records (10 by default) and -f keeps following:
nova tail -f nova://demo/eventsLeave it running and append from a second shell; the new records appear as
soon as they are durable. nova ls demo lists the bucket's streams,
and nova ls alone lists buckets.
The same over HTTP
The HTTP surface is the same API re-encoded as JSON. Inside the compose
stack you can reach it two ways. The Gateway at localhost:8080 is
the front door; it absorbs the cluster's leader redirects. Each node's
data bridge at localhost:7791 through 7793 proxies a foreign-led
stream one hop to its leader, so any node answers any stream. The paths
are identical; the examples use the Gateway.
Keys ride the path as one percent-encoded segment: a key like
orders/eu becomes orders%2Feu. Quote the URL so the shell leaves the
escape alone.
curl -s -X POST localhost:8080/v1/buckets/apps -d '{"create_stream_on_append":true}'
curl -s -X POST localhost:8080/v1/streams/apps/orders%2Feu -d '{"class":"standard"}'With create_stream_on_append set on the bucket, the explicit create is
optional: the first append to a missing key creates the stream from the
bucket's defaults.
# Append records (bodies are UTF-8; use body_b64 for binary).
curl -s localhost:8080/v1/streams/apps/orders%2Feu/records \
-d '{"records":[{"body":"one"},{"body":"two"}]}'
# Read from the start; every response carries the tail position.
curl -s 'localhost:8080/v1/streams/apps/orders%2Feu/records?seq_num=0&limit=10'
# The last record by tail offset; or everything since a timestamp.
curl -s 'localhost:8080/v1/streams/apps/orders%2Feu/records?tail_offset=1'
curl -s 'localhost:8080/v1/streams/apps/orders%2Feu/records?timestamp_ms=0&limit=10'
# The committed tail — one past the last record.
curl -s localhost:8080/v1/streams/apps/orders%2Feu/records/tail
# Follow live as SSE; event ids are resume cursors for Last-Event-ID.
curl -N 'localhost:8080/v1/streams/apps/orders%2Feu/records/subscribe?seq_num=0'
# Conditional append: succeeds only if the next sequence number is exactly 2.
curl -s localhost:8080/v1/streams/apps/orders%2Feu/records \
-d '{"records":[{"body":"three"}],"match_seq_num":2}'
# Drop history below 2 (logically instant; reclaimed in the background).
curl -s -X POST localhost:8080/v1/streams/apps/orders%2Feu/trim -d '{"seq_num":2}'An append answers {"start":…,"end":…,"tail":…}: where the batch landed
and where the stream now ends. A read answers {"records":[…],"tail":…}.
A conditional append whose match_seq_num misses answers
412 Precondition Failed with the current tail in the body.
The same from Go
The SDK speaks gRPC to any node and follows leader redirects on its own.
With the demo/events stream from the steps above:
package main
import (
"context"
"fmt"
"log"
"github.com/supabase/nova/client"
)
func main() {
ctx := context.Background()
c, err := client.New(client.Options{Addrs: []string{"localhost:7781"}})
if err != nil {
log.Fatal(err)
}
defer c.Close()
addr := client.Address{Bucket: "demo", Key: "events"}
// A producer owns an append session; batching is automatic.
p, err := c.OpenStream(ctx, addr)
if err != nil {
log.Fatal(err)
}
res, err := p.Append(ctx, client.Bodies([]byte("first"), []byte("second"))...)
if err != nil {
log.Fatal(err)
}
fmt.Println("appended", res.Start.SeqNum, "through", res.End.SeqNum-1, "tail", res.Tail.SeqNum)
if err := p.Close(ctx); err != nil {
log.Fatal(err)
}
// The committed tail, strongly consistent.
tail, err := c.CheckTail(ctx, addr)
if err != nil {
log.Fatal(err)
}
fmt.Println("tail", tail.SeqNum)
// Ordered live tailing from the start; reconnects and resumes on its own.
for records, err := range c.Subscribe(ctx, addr, 0) {
if err != nil {
log.Fatal(err)
}
for _, r := range records {
fmt.Printf("%d\t%s\n", r.SeqNum, r.Body)
}
}
}Append blocks until the records are durable and returns their
server-assigned range. Subscribe yields batches in order and only ends
when you stop consuming, cancel the context, or the stream is gone.
Tear down
docker compose -f nova-compose.yml down -v-v drops the metastore and object-storage volumes together. Never wipe
one without the other: metadata describing absent objects misparses, and
objects without metadata are garbage.
Where next
Streams
Sequence numbers, the tail, and the stream verbs
Records
Bodies, headers, timestamps, batches, and limits
Buckets
Addresses, keys, defaults, and create-on-append
Guarantees
What is promised, what is not, and the limits
Modeling
A stream per tenant, aggregate, device, or run
Go SDK
The client package end to end
HTTP API
JSON and SSE through the Gateway or the data bridge