Host the full stack
One Linux server runs everything a network needs besides Algorand itself: the web app, one indexer per network, an IPFS
node with a public gateway, this documentation site, and Caddy for HTTPS. ariadne.press runs exactly this stack. The
files are in deploy/ (with its own README.md, the operator’s runbook); this page explains them. Terms such as
gateway and pin are explained in the Glossary.
Caddy (HTTPS, ports 80/443) APP_HOST -> web Next.js app, every enabled network APP_HOST/docs/ -> docs the documentation site (static files) API_HOST -> indexer-<net> /testnet/* and /mainnet/*: one indexer per network IPFS_HOST -> kubo gateway only content this node holds, sandboxed, its own origin kubo RPC internal only: the indexer fetches, checks and pins through it Algorand: public algod and indexer, read from spec/networks.jsonThe app, the indexer APIs and the IPFS gateway are three separate origins (three hostnames): content served by the gateway stays apart from the app’s pages, and the gateway adds a sandboxing security policy of its own.
What you need
Section titled “What you need”- A server with Ubuntu 24.04 or 26.04 LTS, 2 vCPU, 4 GB RAM, 40 GB disk and a public IPv4 address.
- Three DNS
Arecords pointing at it: the app,api.andipfs.(ariadne.press usesariadne.press,api.ariadne.pressandipfs.ariadne.press). - Docker with Compose.
- Open ports: 80 and 443 (TCP, and UDP for 443) for Caddy, 4001 (TCP and UDP) for the IPFS swarm, and SSH.
Caddy obtains certificates from Let’s Encrypt by itself, without an e-mail address.
Services
Section titled “Services”deploy/docker-compose.yml (project name ariadne-stack). Images are pinned by digest: node 26.5.1, ipfs/kubo v0.43.1,
caddy 2.11.2. The repository checkout provides the network manifest: spec/ is mounted read-only into the containers.
| Service | Image | Role | Volumes | Health check |
|---|---|---|---|---|
kubo |
ipfs/kubo v0.43.1 | IPFS node. RPC (5001) and gateway (8080) on the container network only; swarm port published | kubo-data |
none |
indexer-testnet |
built from deploy/indexer.Dockerfile |
the TestNet indexer, profile testnet; database /data/testnet-{appId}.sqlite |
indexer-testnet-data |
GET /health every 60 s |
indexer-mainnet |
the same image | the MainNet indexer, profile mainnet (MainNet is not open yet) |
indexer-mainnet-data |
the same |
indexer-localnet |
the same image | profile local, only for the local stack test |
indexer-localnet-data |
the same |
web |
built from deploy/web.Dockerfile |
the Next.js app on port 3001, for every enabled network | web-content, web-data |
GET /icon.svg every 60 s |
docs |
built from deploy/docs.Dockerfile |
the documentation site (static files) at /docs, served on port 8080 |
none | GET /docs/ every 60 s |
caddy |
caddy 2.11.2 | HTTPS and routing for the hostnames | caddy-data, caddy-config |
none |
Every service restarts automatically unless stopped. COMPOSE_PROFILES chooses which indexers run.
deploy/kubo/init.sh runs before every start of the daemon and sets:
- the RPC and the gateway on the container network only (Caddy publishes the gateway, never the RPC);
Gateway.NoFetchtrue: the public gateway serves only blocks the node already holds (fetched and pinned by the indexer), so it never becomes an open proxy for arbitrary IPFS content;Gateway.NoDNSLinktrue;- the repository size limit from
IPFS_STORAGE_MAX.
The indexers reach the node at http://kubo:5001 (ARIADNE_KUBO_API): they read article folders through it and pin a
second copy of every valid article and of every review and comment.
Routing (Caddyfile)
Section titled “Routing (Caddyfile)”| Hostname | Paths | Goes to |
|---|---|---|
APP_HOST |
/docs/* (and /docs redirected to /docs/) |
docs:8080 |
APP_HOST |
everything else | web:3001 |
LEGACY_APP_HOST |
any | a permanent redirect to APP_HOST, path kept |
API_HOST |
/testnet/*, /mainnet/*, /localnet/* |
the indexer of that network, prefix removed |
API_HOST |
anything else | 404 |
IPFS_HOST |
/ipfs/* |
the Kubo gateway, with security headers |
IPFS_HOST |
anything else | 404 |
The gateway’s answers carry Content-Security-Policy: default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; font-src 'self'; sandbox, X-Content-Type-Options: nosniff and
Access-Control-Allow-Origin: *. A host variable may list several names separated by , , which is how former names
keep working while moving to a new domain.
Environment variables
Section titled “Environment variables”Settings live in deploy/.env on the server, a copy of deploy/vps.env.example that you fill in. Never commit it: it
is ignored by git.
| Variable | Required | Meaning |
|---|---|---|
APP_HOST |
yes | the app’s hostname (also serves /docs/) |
API_HOST |
yes | the indexer APIs’ hostname |
IPFS_HOST |
yes | the IPFS gateway’s hostname |
LEGACY_APP_HOST |
no | a former app hostname, permanently redirected to APP_HOST |
COMPOSE_PROFILES |
yes, once deployed | the networks served: empty until the contract is deployed (an indexer without an application id refuses to start), then testnet, later testnet,mainnet |
IPFS_STORAGE_MAX |
recommended | the IPFS repository size limit, about 40 % of the disk |
HTTP_PORT, HTTPS_PORT, IPFS_SWARM_PORT |
no | published ports (80, 443 and 4001 by default) |
ARIADNE_NETWORKS_JSON |
no | another manifest file inside the containers (the local stack test uses one) |
ARIADNE_DEV_NETWORKS |
no | 1 lets the web app serve development networks (local stack test) |
ARIADNE_WEB_CONTENT_DIR, ARIADNE_WEB_KUBO_API |
no | the web app’s development pin target (local stack test) |
ARIADNE_COOKIE_KEY |
for Zenodo | seals the Zenodo session cookie (secret) |
ARIADNE_ORCID_CLIENT_ID, ARIADNE_ORCID_CLIENT_SECRET |
no | ORCID public API client: higher rate limits for the indexers (secret) |
ARIADNE_ZENODO_CLIENT_ID_TESTNET, ARIADNE_ZENODO_CLIENT_SECRET_TESTNET |
for Zenodo on TestNet | the OAuth application on sandbox.zenodo.org (secret) |
ARIADNE_ZENODO_CLIENT_ID_MAINNET, ARIADNE_ZENODO_CLIENT_SECRET_MAINNET |
for Zenodo on MainNet | the OAuth application on zenodo.org (secret) |
ARIADNE_VAPID_PUBLIC_KEY, ARIADNE_VAPID_PRIVATE_KEY |
for browser notifications | the Web Push key pair (the private key is secret) |
ARIADNE_VAPID_SUBJECT |
no | the contact push services may use |
The compose file sets the internal addresses itself, so they never go in .env: ARIADNE_INTERNAL_INDEXER_TESTNET,
ARIADNE_INTERNAL_INDEXER_MAINNET, ARIADNE_INTERNAL_INDEXER_LOCALNET, ARIADNE_INTERNAL_GATEWAY and
ARIADNE_INTERNAL_ALGOD_LOCALNET for the web server, ARIADNE_PUSH_DB for its subscriptions, and ARIADNE_NETWORK,
ARIADNE_DB and ARIADNE_KUBO_API for each indexer. Browsers use the public URLs of the manifest; server-side calls use
the internal addresses. The indexers’ own settings are described in Run an indexer.
Without the optional secrets the stack still works: without ORCID credentials the indexers read ORCID anonymously; without the Zenodo variables the “Get a DOI with Zenodo” step is hidden (authors can still declare a DOI); without the VAPID keys the inbox says browser notifications are not available.
Secrets
Section titled “Secrets”- Secrets are created on the server, or by the operator’s own registrations, and written only into
deploy/.envon the server. They never go into the repository, a chat or a log. - The cookie key is 32 random bytes as hex, for example generated on the server with
openssl rand -hex 32. - The VAPID keys are generated on the server by
deploy/vapid-keys.mjs, which appends them to.envwithout showing them (see below). Changing them makes every browser subscribe again. - ORCID and Zenodo client secrets come from the operator’s registrations. ORCID: Developer Tools, public API, redirect
URI
https://APP_HOST/api/orcid/callback. Zenodo: an OAuth application on sandbox.zenodo.org for TestNet and one on zenodo.org for MainNet, redirect URIhttps://APP_HOST/api/zenodo/callback, and a communityariadne-algoon both (its name goes inzenodoCommunityofspec/networks.json). - Authors’ pinning keys (Filebase, Pinata) never reach the server: they stay in each author’s browser.
deploy/.envis the only file on the server worth keeping a copy of.
First installation
Section titled “First installation”The repository has no remote; it travels as a git bundle. On your computer, in the repository:
git bundle create ariadne.bundle --allscp ariadne.bundle root@SERVER_IP:/root/On the server:
apt-get update && apt-get -y upgradecurl -fsSL https://get.docker.com | sh # Docker's official installerufw allow 22/tcp && ufw allow 80,443/tcp && ufw allow 443/udp && ufw allow 4001 && ufw --force enablegit clone /root/ariadne.bundle /opt/ariadnecd /opt/ariadne/deploycp vps.env.example .envnano .env # the three hostnames; COMPOSE_PROFILES=testnet once deployeddocker compose up -d --build # 5 to 10 minutes the first timedocker compose ps # every service "Up"Then check that https://APP_HOST/testnet opens the app, that https://API_HOST/testnet/status answers JSON with
"network":"testnet", and that https://IPFS_HOST/ answers “not found” (the gateway serves only /ipfs/<cid> paths).
Deploying the contract on a network is done once, from the operator’s computer (algokit project deploy testnet in
projects/contracts): it writes the application id, genesis hash, governance and start round into
spec/networks.json. Fill indexerApi (https://API_HOST/testnet) and ipfsGateway (https://IPFS_HOST), set
enabled to true, commit, and update the server. See Networks and addresses.
Updating
Section titled “Updating”On your computer, create the bundle again and copy it. On the server:
cd /opt/ariadne && git pull /root/ariadne.bundle maincd deploy && docker compose up -d --build| Change | Command on the server |
|---|---|
| code of the app or the indexer | docker compose up -d --build |
spec/networks.json only (an application id, enabling a network) |
docker compose restart |
the documentation (projects/docs, or spec/ARIADNE_SPEC.md, DECISIONS.md, spec/taxonomy.json, which the site copies) |
docker compose up -d --build docs |
deploy/.env (secrets, hostnames) |
docker compose up -d web indexer-testnet (or the services concerned) |
A new contract version is a new application. The indexer then opens /data/testnet-<appId>.sqlite, a clean database;
the previous one stays in the volume, and its content stays pinned.
Browser notifications
Section titled “Browser notifications”The web server sends Web Push notifications itself: no outside account, only a key pair that stays in deploy/.env.
Once, on the server:
cd /opt/ariadne/deploygrep -q ARIADNE_VAPID_PRIVATE_KEY .env || docker run --rm -v "$PWD/vapid-keys.mjs:/k.mjs:ro" node:26.5.1-bookworm-slim node /k.mjs >> .envdocker compose up -d webSubscriptions live in the web-data volume (/data/push.sqlite).
Backups
Section titled “Backups”Almost nothing needs a backup, because the chain is the source of truth:
| Data | Where | If it is lost |
|---|---|---|
deploy/.env |
the server | keep a copy: it holds the hostnames and the secrets |
| indexer databases | indexer-<network>-data |
rebuilt from the chain, from startRound, then content and outside checks are asked again |
| IPFS second copies | kubo-data |
the indexer pins again what it validates, as long as the authors’ own pins keep the content reachable. It remembers in its database what it already pinned, so it pins everything again only when it starts from a fresh database |
| push subscriptions | web-data |
people turn browser notifications on again |
| certificates | caddy-data |
Caddy obtains new ones |
Health checks and monitoring
Section titled “Health checks and monitoring”On the server:
docker compose psshowshealthyorunhealthyfor the web app, each indexer and the docs site. An indexer turns unhealthy when its/healthanswers 503: no successful ingestion poll for 5 minutes (the Algorand node unreachable, or a round the indexer refuses).docker compose logs --tail=50 indexer-testnetsays why ingestion stopped.
Docker restarts a container that crashes, but nobody hears of a server that is down. A free outside uptime monitor (checking every 5 minutes, alerting by e-mail or phone) watches three addresses, each of which must answer 200:
| What | Address |
|---|---|
| the app | https://ariadne.press/testnet |
| the indexer (503 when ingestion has stalled for 5 minutes) | https://api.ariadne.press/testnet/health |
| the IPFS gateway, with an identity CID that needs no fetching | https://ipfs.ariadne.press/ipfs/bafkqaaa |
For a human view, the app’s status page (https://ariadne.press/testnet/status, linked from the footer) shows what each part reports, refreshed every 15 seconds: the application and its deposits, the Algorand node and indexer, the ARIADNE indexer and its second copy, the ORCID and DOI checks, the website and the IPFS gateway, and the outside services.
Local stack test
Section titled “Local stack test”The same stack runs on a development computer against LocalNet, with *.localhost names and Caddy’s own certificate
authority:
# LocalNet running and the contract deployed (projects/contracts: algokit project deploy localnet)cd deploynode local-manifest.mjsdocker compose --env-file local-stack.env up -d --buildcd ../projects/web && ARIADNE_STACK=1 npx vitest run test/stack # end-to-end acceptancecd ../../deploy && docker compose --env-file local-stack.env downlocal-manifest.mjs writes deploy/networks.local.json, the manifest with the LocalNet entry pointed at the stack’s
local names. The browser reaches the app at https://app.localhost:8443/localnet (accept the local certificate once).
The acceptance test publishes a folder, waits for the containerised indexer to validate and pin it, checks that the
gateway serves it with the sandboxing headers and refuses a CID it does not hold, and renders the article through the
app.