Start here
Nova Operator Quickstart
This walkthrough takes you from a fresh clone to a running Nova node that serves an uploaded image over HTTPS. At the end you will have:
- the full production stack (coordinator + embedded IPFS, Postgres, nginx two-vhost TLS front, certbot) running under Docker Compose;
- a master key you have backed up, an operator account, and a rendered
operator.yaml; - an image uploaded through the public API and served back at a content-addressed URL, with on-the-fly resizing;
- the operator admin console reachable on the loopback admin vhost.
Everything below is copy-pasteable in order. Deep material — backups, key rotation, moderation, legal posture — is linked at the end, not duplicated here.
Prerequisites
- A Linux host with Docker and the compose v2 plugin
(
docker compose versionworks;docker-composev1 is not tested). - Roughly 5 GB of free disk for the image build (Go + Node multi-stage build cache) and volumes, plus whatever you intend to store.
- A public DNS hostname pointing at the host — only if you choose
the
http-01(Let's Encrypt) TLS mode. Every other mode works without one. - Nothing else. No Go, no Node, no libvips on the host — everything ships inside the coordinator image.
1. Clone and start setup mode
Clone the repository, seed the compose environment file, and set a real Postgres password:
git clone git@github.com:nova-archive/nova.git
cd nova
cp docker/.env.example docker/.env
sed -i "s/changeme/$(openssl rand -hex 16)/" docker/.env
Start the setup profile — Postgres, the coordinator in first-run setup mode, and a loopback-only nginx that fronts the wizard:
docker compose -f docker/docker-compose.yml --env-file docker/.env --profile setup up -d
The first run builds the coordinator image; expect a few minutes.
Setup is locked to whoever holds the node's bootstrap token, which the coordinator prints to its log on startup. Retrieve it:
docker compose -f docker/docker-compose.yml --env-file docker/.env logs coordinator | grep bootstrap_token
You will see a line like:
nova-coordinator | 2026/06/10 16:04:11 WARN coordinator SETUP MODE: bootstrap token (present as the X-Nova-Setup-Token header in the wizard) — copy it from this log bootstrap_token=aa9e9198cfec47c804350a72a1ba3aba
Copy the value after bootstrap_token=, then open the wizard at:
http://127.0.0.1:8444/setup/
The wizard is deliberately bound to loopback. If your node is a remote server, tunnel the port instead of exposing it:
ssh -N -L 8444:127.0.0.1:8444 you@your-server
2. The wizard, step by step
The wizard is a linear stepper; Next stays disabled until each step's requirements are met. You only do this once.
Bootstrap token. Paste the token you copied from the coordinator log. Every wizard request carries it, so nobody who merely reaches the port can configure your node.
Welcome. Enter the node's public hostname (e.g.
nova.example.org), a contact email, and an optional display name.
By continuing you agree to operate the node responsibly under the
bundled terms.

Your master key. The wizard generates the key that encrypts all of
your node's secrets, shows it once, and offers a backup download.
This is the one unrecoverable step in the entire setup: if you
lose the master key, your node's sealed secrets — and with them your
stored content — are gone for good, federation-wide. Click
Download backup, store nova-master-key.txt somewhere offline,
then type the key's fingerprint into the confirm field. The readback
exists precisely so you cannot click past this step without proving
you captured the key; Next stays disabled until the typed fingerprint
matches. Your IPFS swarm identity and content-signing keys are
generated and sealed automatically alongside the master key — nothing
to enter for those.

Operator account. Email and password (12 characters minimum) for
the first operator — this is the account you will use to sign in to
the admin console at /admin.
TLS & certificates. Pick how Nova obtains its HTTPS certificate.
Each mode shows its privacy cost inline — notably, http-01 publishes
your hostname to public Certificate Transparency logs. See
Choosing a TLS mode below; dev-self-signed
is fine for kicking the tires. static additionally asks for your
certificate and key paths. Load-bearing terms (fingerprint, Certificate
Transparency) carry an ⓘ button with a plain-English explanation.

Public uploads. Toggle whether anyone may upload through your
node's public widget. If you enable it, a terms-of-service URL is
required (a template lives at
legal/TOS_TEMPLATE.md).
Privacy & hardening. A tri-state "Harden privacy (paranoid)"
parent toggle over three individually exposed settings: whether to
record uploader IPs (record_source_ip), how long to keep IP logs
(1 day vs. 30), and whether to keep pinned CIDs off the public IPFS
DHT (public_ipfs_dht). Each constituent shows its consequence inline;
two additional rows (no outbound webhooks; metrics loopback-only) are
informational. Fully checking all three commits paranoid: true in
operator.yaml. ⓘ disclosures explain source-IP recording and the
public IPFS DHT in plain language at the point of decision.
Review. Confirm your answers (secrets are not shown) and submit.

Commit. Committing writes operator.yaml, renders the two-vhost
nginx config, creates your operator account, and seals the generated
keys with your master key. This finalizes setup — make sure the
master-key backup is safe before you click Commit & go live.
You're live. The orientation page shows the admin console link and the widget embed snippet (both repeated below). The coordinator exits cleanly behind the scenes and compose restarts it in normal mode.

3. Restart into production
Swap the setup profile for the prod profile, which brings up the real TLS front (wizard-rendered config) and the certbot sidecar:
docker compose -f docker/docker-compose.yml --env-file docker/.env --profile setup down
docker compose -f docker/docker-compose.yml --env-file docker/.env --profile prod up -d
Now live on the host:
| Port | What |
|---|---|
8443 |
Public vhost — HTTPS: blob reads, uploads, transforms, the widget |
8442 |
Public vhost — HTTP: redirect + ACME challenge (http-01) |
127.0.0.1:8445 |
Admin vhost — HTTPS: admin console + admin API, loopback only |
Host ports 80/443 are deliberately left free — Nova never grabs
privileged ports or clobbers an existing web server. For a public
deployment, forward :80 → :8442 and :443 → :8443 at your firewall
or edge.
Check that everything is up:
docker compose -f docker/docker-compose.yml --env-file docker/.env --profile prod ps
All four services — postgres, coordinator, nginx, certbot —
should report (healthy) within a minute or so. Then confirm the
public vhost answers (with dev-self-signed TLS, -k accepts the
self-signed chain and --resolve points your wizard hostname at the
box; with a real certificate and DNS you need neither):
curl -ks --resolve "nova.example.org:8443:127.0.0.1" https://nova.example.org:8443/health
4. First upload
Option A — the upload widget
The orientation page already showed you the embed. Drop this on any page served from your public vhost:
<script src="/widget/nova-upload-widget.js" defer></script>
<div data-nova-upload-widget data-product="image"></div>
A ready-made demo page ships at https://<your-host>:8443/widget/ —
open it and drag an image onto the widget. Anonymous widget uploads
require public uploads to be enabled (the wizard toggle); otherwise
mount the widget via JS with a getToken provider (see the
operator checklist).
By default those uploads are private (they join no collection); set
uploads.default_collection_id to a public collection — see Option B —
to make widget uploads publicly viewable without per-upload wiring.
Authenticated / off-origin embedding. To accept authenticated uploads — or to embed the widget on a different origin (e.g. your own site) without enabling anonymous public uploads — mint a scoped, revocable upload token and hand it to the widget:
novactl auth login # operator, against the admin vhost
novactl upload-token create --product image --label my-site
# prints a nova_ut_… secret ONCE — store it; serve it to your page from your backend
NovaUploadWidget.mount('#uploader', {
product: 'image',
getToken: async () => 'nova_ut_…', // your backend supplies this
});
For a cross-origin host page, also set uploads.cors.enabled: true with
your site in allowed_origins. Full flow, the security note about
embedded tokens, and the uploads.limits.* backstops are in the
operator checklist.
Option B — curl
One honest caveat first: uploads are private by default. A blob's
visibility comes from the collections it belongs to, and a blob in no
collection resolves to private — uploading works, but anonymous reads
return 401/403. So first create a public collection (no raw SQL —
novactl collection create is DB-direct, like novactl node):
novactl collection create --name "Public" --slug public --visibility public
# prints: created collection <COL_ID> (slug=public visibility=public owner=…)
--owner defaults to your sole operator account; pass --owner <user-uuid>
if you have several. Now make uploads land in it, either:
- Per upload — pass
collection_id=<COL_ID>(curl below), or mint a token bound to it and hand it to the widget'sgetToken:novactl upload-token create --product image --collection <COL_ID>. - By default — set
uploads.default_collection_id: <COL_ID>(vianovactl config set uploads.default_collection_id <COL_ID>, the admin Settings screen, oroperator.yaml). Then any upload with no explicit collection — including anonymous widget uploads — joins it automatically. Point it at a public collection only if you intend those uploads to be publicly viewable.
Upload an image — product=image is what makes the transform routes
accept it (collection_id is optional once a default is set):
curl -ks --resolve "nova.example.org:8443:127.0.0.1" \
-F "file=@photo.png;type=image/png" \
-F "product=image" \
-F "collection_id=<COL_ID>" \
https://nova.example.org:8443/api/v1/blobs
You get a 201 with a JSON body containing the blob's cid. Serve it
back, byte-identical, and resized:
curl -ks --resolve "nova.example.org:8443:127.0.0.1" -o roundtrip.png \
https://nova.example.org:8443/blob/<cid>
curl -ks --resolve "nova.example.org:8443:127.0.0.1" -o thumb.png \
https://nova.example.org:8443/i/<cid>/w320.png
Allowed transform widths are 320, 512, 1024, and 2048; anything
else returns 400. Anonymous curl uploads, like the widget, require
public uploads enabled — otherwise log in first (see below) and pass
-H "Authorization: Bearer $TOKEN".
That's it — you are uploading and serving.
5. The admin console
The operator console lives on the admin vhost:
https://<your-host>:8445/admin
Sign in with the admin email and password you gave the wizard. From there you can browse and delete blobs, inspect audit results, and run key rotation.
The Settings screen (operator-only) tunes a running node against
the same config backend as novactl config: live upload limits and CORS
apply immediately; privacy posture (source-IP recording, retention,
public-DHT) and public-uploads/ToS are restart-class and the screen says
so per field. It also shows the full effective config read-only with
live/restart/env badges. The long tail (auth, TLS, moderation) stays on
novactl config set/apply — see below.
The admin vhost is bound to 127.0.0.1 on the host by default — it is
not reachable from the network, on purpose. To use it remotely, prefer
an SSH tunnel:
ssh -N -L 8445:127.0.0.1:8445 you@your-server
# then browse https://<your-host>:8445/admin locally
If you decide to expose it deliberately (e.g. on a management VLAN),
copy docker/docker-compose.override.yml.example to
docker/docker-compose.override.yml and add a ports entry for the
nginx service bound to a private interface — never 0.0.0.0.
The same login works against the admin API directly, e.g. for scripted bearer tokens:
curl -ks --resolve "nova.example.org:8445:127.0.0.1" \
-H 'Content-Type: application/json' \
-d '{"username":"you@example.org","password":"your-password"}' \
https://nova.example.org:8445/api/v1/auth/login
Next: the settings
Everything you can configure — TLS modes and their privacy trade-offs,
headless/scripted setup, external OIDC, the federation block — is in
docs/reference/operator-configuration.md.
This page deliberately stops at "it works".
Next steps
- Read the operator checklist —
legal/OPERATOR_CHECKLIST.mdis the deep runbook. Non-negotiables first: - Back up the secrets volume. The wizard generated your master
key, swarm key, and signing key into the
nova-secretsvolume; back it up out-of-band before you accept traffic. The paper backup the wizard made you download is the floor, not the ceiling. - Master-key rotation — the five-step runbook when you need to rotate.
- Moderation and DMCA — ToS, designated-agent registration, and takedown procedure if you host public uploads.
- Re-arming the wizard — deleting
.bootstrap-completefrom the config volume puts the node back in setup mode; that is the documented redo path, and a thing to be careful of. - Inspect and tune settings at runtime — once the node is up you do
not need to edit
operator.yamlby hand. Usenovactl config:
sh
novactl auth login # operator, against the admin vhost
novactl config get --effects # print effective config + per-field source/effect
novactl config set uploads.limits.max_concurrent_global 8 # live — no restart needed
novactl config set auth.issuer_url https://idp.example/ # restart-required — flagged in response
Fields marked live take effect immediately in process. Fields marked
restart are persisted and validated now but need a coordinator restart to
apply — the response lists them in restart_required. See
legal/OPERATOR_CHECKLIST.md
for the full API reference, effect-class table, env-override behaviour,
and optimistic-concurrency (If-Match) details.
- See where the project is going —
ROADMAP.mdcovers the rest of Phase 1 and the Phase 2 federation work (collection APIs, donor storage nodes, streaming envelope).
Source:
docs/quickstart.md