novadocs

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:

Fetch the compose file and start the stack
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 -d

You get three running novad instances (three stars, one per availability zone) plus the metastore, object storage, and the Gateway:

ServiceURLNotes
MinIO consolehttp://localhost:9201nova / novatest
novad-1/2/3 gRPC data planelocalhost:7781 / 7782 / 7783what nova and the Go SDK dial
novad-1/2/3 HTTP data bridgelocalhost:7791 / 7792 / 7793the same API as JSON, for callers inside the trust domain
Gatewayhttp://localhost:8080the internet-facing HTTP front door
novad-1/2/3 metrics and pproflocalhost:9191 / 9192 / 9193/metrics, /healthz, /readyz, /debug/pprof/
Admin dashboardhttp://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

Install the CLI
go install github.com/supabase/nova/cmd/nova@latest
export NOVA_ADDR=localhost:7781

NOVA_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:

Create a bucket, then a stream in it
nova bucket create demo
nova stream create --bucket demo --key events

stream 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:

Append two records
printf 'hello\nworld\n' | nova append nova://demo/events
appended 2 entries

The first record gets sequence number 0, the second 1. Sequence numbers are contiguous and never change.

Read them back

Read from the start
nova read nova://demo/events --from 0

Each 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

The committed tail
nova check-tail nova://demo/events
2	2026-08-24T10:15:02Z

The 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:

Tail and follow
nova tail -f nova://demo/events

Leave 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.

Create a bucket and a stream over HTTP
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, read, tail, subscribe
# 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:

main.go
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

Stop the stack and wipe its volumes
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

On this page