Skip to content

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

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

  • 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 A records pointing at it: the app, api. and ipfs. (ariadne.press uses ariadne.press, api.ariadne.press and ipfs.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.

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.NoFetch true: 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.NoDNSLink true;
  • 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.

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.

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 are created on the server, or by the operator’s own registrations, and written only into deploy/.env on 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 .env without 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 URI https://APP_HOST/api/zenodo/callback, and a community ariadne-algo on both (its name goes in zenodoCommunity of spec/networks.json).
  • Authors’ pinning keys (Filebase, Pinata) never reach the server: they stay in each author’s browser.
  • deploy/.env is the only file on the server worth keeping a copy of.

The repository has no remote; it travels as a git bundle. On your computer, in the repository:

Terminal window
git bundle create ariadne.bundle --all
scp ariadne.bundle root@SERVER_IP:/root/

On the server:

Terminal window
apt-get update && apt-get -y upgrade
curl -fsSL https://get.docker.com | sh # Docker's official installer
ufw allow 22/tcp && ufw allow 80,443/tcp && ufw allow 443/udp && ufw allow 4001 && ufw --force enable
git clone /root/ariadne.bundle /opt/ariadne
cd /opt/ariadne/deploy
cp vps.env.example .env
nano .env # the three hostnames; COMPOSE_PROFILES=testnet once deployed
docker compose up -d --build # 5 to 10 minutes the first time
docker 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.

On your computer, create the bundle again and copy it. On the server:

Terminal window
cd /opt/ariadne && git pull /root/ariadne.bundle main
cd 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.

The web server sends Web Push notifications itself: no outside account, only a key pair that stays in deploy/.env. Once, on the server:

Terminal window
cd /opt/ariadne/deploy
grep -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 >> .env
docker compose up -d web

Subscriptions live in the web-data volume (/data/push.sqlite).

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

On the server:

  • docker compose ps shows healthy or unhealthy for the web app, each indexer and the docs site. An indexer turns unhealthy when its /health answers 503: no successful ingestion poll for 5 minutes (the Algorand node unreachable, or a round the indexer refuses).
  • docker compose logs --tail=50 indexer-testnet says 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.

The same stack runs on a development computer against LocalNet, with *.localhost names and Caddy’s own certificate authority:

Terminal window
# LocalNet running and the contract deployed (projects/contracts: algokit project deploy localnet)
cd deploy
node local-manifest.mjs
docker compose --env-file local-stack.env up -d --build
cd ../projects/web && ARIADNE_STACK=1 npx vitest run test/stack # end-to-end acceptance
cd ../../deploy && docker compose --env-file local-stack.env down

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