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 (
.nvmrcpins 24.21.0, andenginesrequires>=24) - pnpm 12 (
packageManagerpinspnpm@12.10.1, andenginesrequires>=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 devThe 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 -dCompose runs two services:
| Service | What it is |
|---|---|
proxy | Caddy 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 |
app | The 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 goes | Variables |
|---|---|
| App build args | NEXT_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 runtime | MEDIATOR_SECRET, MEDIATOR_ALLOWED_ORIGIN, METRICS_ADMIN_TOKEN, TRUSTED_PROXY_HOPS (default 1), and METRICS_DB_DIR, fixed to /app/data |
| Proxy runtime | SITE_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- It downloads the archive and checks it with
gh attestation verify. The archive must be signed byrelease.ymlin this repository, from the commit the tag points to, on a GitHub-hosted runner. - It copies the repository at that commit to the host, followed by the archive.
- It starts the stack with
docker-compose.release.ymllayered on top. That file builds the app image fromrelease/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 anyX-Forwarded-Fora client sends and sets it to the connecting address. - A CDN or another proxy in front of Caddy: add it under Caddy's
trusted_proxiesand raiseTRUSTED_PROXY_HOPSto the number of proxies. - Your own proxy instead of Caddy: it has to overwrite or append
X-Forwarded-Forwith 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=referenceand setMEDIATOR_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.
- Generate a fresh Stellar keypair, for example with Stellar Laboratory or
Keypair.random()from the Stellar SDK, and keep its secret seed (S...). - Set
MEDIATOR_SECRETto that seed in the server's environment. - 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/metricsContent 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.
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