Account Demolisher
Operations

Self-hosting

Install, configure, run and deploy Account Demolisher under your own domain, behind the reverse proxy it requires.

Account Demolisher is a Next.js 16 application served by next start. You run it behind a reverse proxy: the repository's Docker Compose file ships one.

Requirements

  • Node.js 24 (.nvmrc pins 24.21.0, and engines requires >=24)
  • pnpm 12 (packageManager pins pnpm@12.10.1, and engines requires >=12)
  • For the container route, Docker with Compose

Local development

git clone https://github.com/bytemaster333/account-demolisher.git
cd account-demolisher
pnpm install --frozen-lockfile
cp .env.example .env.local
pnpm dev

The dev server runs at http://localhost:3000. When a request carries no X-Forwarded-For, Next fills it with the connection's address, so the default TRUSTED_PROXY_HOPS=1 works on localhost.

Docker Compose

docker compose up --build -d

Compose runs two services:

ServiceWhat it is
proxyCaddy 2.11.7, pinned by digest. The only published service, on ports 80 and 443 (TCP and UDP). It refuses request bodies over 64 KB and proxies to app:3000
appThe app image. It is not published: only the proxy reaches it, on the Compose network

Compose reads a .env file next to docker-compose.yml only to fill in its own ${...} values. No other variable reaches the containers.

Where it goesVariables
App build argsNEXT_PUBLIC_STELLAR_NETWORK (default testnet), NEXT_PUBLIC_DEPLOYMENT_MODE (default self-hosted), NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_SLIPPAGE_BPS, NEXT_PUBLIC_MIN_MARKET_OUT_STROOPS, NEXT_PUBLIC_LUMEUS_TESTNET_KEY, NEXT_PUBLIC_LUMEUS_MAINNET_KEY
App runtimeMEDIATOR_SECRET, MEDIATOR_ALLOWED_ORIGIN, METRICS_ADMIN_TOKEN, TRUSTED_PROXY_HOPS (default 1), and METRICS_DB_DIR, fixed to /app/data
Proxy runtimeSITE_ADDRESS: your domain, for which Caddy obtains and renews a TLS certificate automatically. The default :80 serves plain HTTP on any host name

The public variables are build arguments because next build inlines them. After changing one, rebuild with docker compose up --build -d.

Compose defaults NEXT_PUBLIC_DEPLOYMENT_MODE to self-hosted, so exchange closes stay off until you set it to reference together with MEDIATOR_SECRET.

Three named volumes keep state across container recreation: metrics (the metrics store at /app/data), and caddy-data and caddy-config (certificates and Caddy state).

The image

The Dockerfile builds on node:24-slim pinned by digest to the 2026-10-06 build of Node.js 24.21.0, so the base cannot drift under the tag. The digest is updated by hand when Node.js moves or the tag is rebuilt with newer Debian packages. The build installs with --frozen-lockfile, builds with the public build arguments, and ships only production dependencies. The runtime image has no pnpm: it starts node node_modules/next/dist/bin/next start as the non-root user appuser (uid 1001), exposes port 3000, and checks health by fetching / every 30 seconds. The app files (.next, node_modules, public and the config) are copied in as root, so appuser cannot change them. /app/data is the one directory under /app that appuser can write, and it is declared as a volume. The build context is an allow-list (.dockerignore), so .env files and anything else outside the sources never reach the builder.

Run a signed release

Publishing a GitHub release runs .github/workflows/release.yml. It repeats the checks CI runs (typecheck, lint, format, unit and integration tests, the dependency audit, end-to-end tests), builds the app, and attaches account-demolisher-<tag>.tar.gz to the release. The archive holds .next, public, package.json, next.config.ts and the lockfile. A signed SLSA build-provenance attestation records that this workflow built the archive from the tagged commit. The public build settings are baked in at that point: the repository's variables, or else testnet, reference, the reference WalletConnect project id and https://demolisher.app.

scripts/deploy-release.sh runs that archive on a Compose host instead of building from source:

scripts/deploy-release.sh v1.0.0 user@host -i ~/.ssh/key.pem
  1. It downloads the archive and checks it with gh attestation verify. The archive must be signed by release.yml in this repository, from the commit the tag points to, on a GitHub-hosted runner.
  2. It copies the repository at that commit to the host, followed by the archive.
  3. It starts the stack with docker-compose.release.yml layered on top. That file builds the app image from release/Dockerfile, which refuses any archive whose SHA-256 differs from the verified one, installs the archive's production dependencies from its lockfile, and runs the same non-root image as the source build.

The host keeps its own .env for the runtime secrets. The script never reads or sends it.

Proxy and rate limits

Every API route is rate-limited per client address. A route handler has no peer address, so the limiter reads it from X-Forwarded-For: the entry TRUSTED_PROXY_HOPS places from the right, which the nearest trusted proxy appended. Everything to its left is client-controlled and ignored. An IPv6 client is keyed by its /64 prefix.

The app is supported only behind a reverse proxy, and TRUSTED_PROXY_HOPS is a whole number of at least 1:

  • The shipped Caddy proxy: 1, the default. Caddy ignores any X-Forwarded-For a client sends and sets it to the connecting address.
  • A CDN or another proxy in front of Caddy: add it under Caddy's trusted_proxies and raise TRUSTED_PROXY_HOPS to the number of proxies.
  • Your own proxy instead of Caddy: it has to overwrite or append X-Forwarded-For with the connecting address, and you set the hop count to match.

The server checks the value when it starts and exits on 0 or anything that is not a whole number. A request whose header has fewer entries than the hop count, or whose trusted entry is not an IP address, answers 500 PROXY_MISCONFIGURED and is logged, rather than sharing one bucket with every other client.

The limiter holds up to 20,000 client keys per route and evicts the least recently used one, so a flood of rotating addresses can neither exhaust memory nor lock new clients out. It lives in process memory, per instance.

Do not publish the app's port 3000 directly. Next fills X-Forwarded-For with the connection's address only when the request has none, so a client talking to the app directly can choose its own rate-limit key.

Configure

Every variable, with its default and validation, is in Configuration. Two decisions matter:

  • To support exchange closes, build with NEXT_PUBLIC_DEPLOYMENT_MODE=reference and set MEDIATOR_SECRET.
  • To leave them out, build with NEXT_PUBLIC_DEPLOYMENT_MODE=self-hosted. A close that needs the mediator, to a registry exchange or a destination that publishes the SEP-29 memo flag, is refused before it starts, and direct closes work as usual.

Lumeus history keys

Soroban RPC keeps about seven days of contract events. With a Lumeus key for a network, the allowance, held-token and Aquarius scans also read the account's whole history from Lumeus, a hosted Stellar history index, and every result is still read on chain before the plan uses it. The keys are optional: without one, the scans run on the RPC window alone.

The browser calls Lumeus directly, so the key ships in the page. Only a publishable key (lum_pub_...) for your own site's origin is accepted, and any other key fails the build. Set it as NEXT_PUBLIC_LUMEUS_TESTNET_KEY or NEXT_PUBLIC_LUMEUS_MAINNET_KEY and rebuild.

The mediator secret

You need MEDIATOR_SECRET only for exchange closes.

  1. Generate a fresh Stellar keypair, for example with Stellar Laboratory or Keypair.random() from the Stellar SDK, and keep its secret seed (S...).
  2. Set MEDIATOR_SECRET to that seed in the server's environment.
  3. Restart the app.

Do not fund it. The seed is only an HMAC master: each exchange close derives its own throwaway mediator key from it, and the closing account creates and funds that mediator with 2 XLM in its final transaction. Use a dedicated seed per deployment, and keep it as secret as a signing key, because whoever holds it can derive every mediator key. If you rotate it, finish or recover any interrupted forwards first: flow tokens and mediator keys from the old seed stop working.

See Mediator forward protocol.

Metrics

The app records first-party usage events in METRICS_DB_DIR (closes.jsonl and counters.json); see Usage metrics. The same directory holds cosign.jsonl, the record of every forward the mediator co-signs (see The co-sign log). The directory must be writable by the app's user, because the mediator refuses to co-sign when it cannot record the signature; the Docker image uses /app/data on the metrics volume. Set METRICS_ADMIN_TOKEN to a random value of at least 16 characters to read the totals:

curl -H "Authorization: Bearer $METRICS_ADMIN_TOKEN" https://yourdomain.example/api/metrics

Content Security Policy

The CSP is built in src/lib/config/csp.ts and set on every document response by src/proxy.ts. connect-src follows the endpoints in src/lib/config/networks.ts, plus the hosts listed in csp.ts itself (the Aquarius API, Lumeus and WalletConnect). Any other host the browser needs goes into csp.ts, or the browser blocks it.

See Security model.

Your own SEP-1 declaration

public/stellar.toml carries the reference deployment's organization details and canonical URL. Replace them with your own, and set NEXT_PUBLIC_SITE_URL to your origin. The app serves the file at /.well-known/stellar.toml too, with the CORS and content-type headers SEP-1 asks for.

See SEP support.

Run one instance

The signing relay and the rate limiter keep their state in process memory, and the metrics store and the co-sign log serialize their writes inside one process. Behind a load balancer, instances do not share signing requests, and a co-signer routed to another instance does not find the request. Run one instance.

See Signing relay API.

Choosing your RPC

Horizon and Soroban RPC endpoints are pinned in src/lib/config/networks.ts, not set by environment variables. The mainnet Soroban RPC default is a third-party gateway; review it, or point it at an endpoint you operate.

See Networks and endpoints.

The docs site

This documentation is a separate Next.js app in docs/, tracked in the same repository. It runs on port 3020:

cd docs
pnpm install --frozen-lockfile
pnpm dev

On this page