#Do I need a database?
No, to start. Yes, before you enforce money on a runtime that keeps its own receipts.
You can install Anvil, protect an action, and genuinely block calls today with nothing but Anvil running. A database is only required for one thing — and it is worth understanding which, and whether it is even yours to provide.
#What the database is actually for
Anvil makes two promises. It blocks calls your policy says to block, and it never lets the same payment happen twice.
The first promise needs nothing. When Anvil refuses a call, that call never ran, so there is nothing to remember.
The second one needs a notebook. To know that a refund already went out, Anvil has to have written that down somewhere that survives a crash — otherwise a retry after a restart can send it again. That notebook is Postgres.
So the rule is simple: a database is for money, not for saying no.
What you give up by skipping it is memory across a restart, not protection today. Without Postgres the dedup claim lives in Redis alone — a cache with a TTL — so a retry that arrives after the process restarts may find no record of the first attempt and run again. For a webhook or a delete that is usually fine, which is why the day-one path is genuinely a path. For money it is the whole risk, which is why money is where the requirement lands.
| What you're enforcing | Database needed? |
|---|---|
| A webhook, an email, a delete, a third-party call | No |
| A payment action — a charge, a refund, a payout, a transfer | Yes |
Anything else Anvil sees an amount on | Yes |
A payment action needs a database whether or not the call carries an amount. A full refund has no amount but still moves the whole captured sum, and a duplicate of it is a real double refund — so it is money and it fails closed without a database, exactly like a partial one. The only things that enforce with no database are calls that move no money at all: webhooks, deletes, sends, ordinary API calls.
Anvil decides that two ways, and only two. The call declares an amount — always enough. Or the action name gives it away: one of the known payment actions (payment.charge, payment.capture, payment.refund, subscription.create, subscription.change), a name whose first segment is payment/payments or payout/payouts, or any segment that reads refund, payout, disbursement or chargeback. So billing.refund is money and lead.capture is not. If you want a money action treated as money, the surest route is to give it an amount rather than hoping the name matches.
#What happens if you enforce money without one
Anvil refuses the call, loudly, with a setup error — every time, starting with the first one. It does not quietly run the payment unprotected. That is deliberate: a money guarantee that a restart can silently drop is worse than no guarantee, because you would be relying on it.
Ghost never throws for this. A money action observed without durable receipts runs exactly as it always did, is recorded, and is stamped non-durable so the observation is never mistaken for a clean one — and Anvil warns once per action rather than on every call. That is what makes watch it in ghost first safe advice for a money action even before you have provisioned anything.
ANVIL_SKIP_DURABILITY_CHECK=true turns the refusal off so a test suite can run without a database — and it is ignored when NODE_ENV=production, so it cannot be the reason a real money deploy ran unprotected.
The dashboard tries to stop you first. Click Enforce on a money action from + Protect new while a live runtime is reporting that it cannot write durable receipts, and Anvil refuses the click and shows you the setup step instead.
That guard needs a runtime to be reporting, so it is not the whole story. The plain Ghost → Enforce toggle on an action row does not consult it, and neither does a workspace with nothing running yet — an unknown runtime deliberately does not get to veto a flip. In those cases the refusal lands where it always does, at the call: the first guarded money call throws the setup error above, and the action's row turns red and says these calls are failing right now. You are told either way; the dashboard just gets to tell you earlier when it can.
#Setting one up
You need a Postgres database and one environment variable. There is no code to write — Anvil registers everything itself when the variable is present.
#The fastest path
In the dashboard, click Enforce on a money action. When Anvil asks for a database, paste a connection string into the box and press Check it. Anvil will connect, confirm it can write, and create its own tables — then hand you the exact line to add to your app.
Don't have one? Neon's free plan works, with no time limit and no card. Railway gives $5 of trial credit for 30 days and then bills. Either takes about two minutes to get a connection string.
#By hand
# 1. Point Anvil at your database.
export ANVIL_POSTGRES_URL="postgres://user:password@host:5432/dbname"
# 2. Create Anvil's tables. Idempotent — safe to run on every deploy.
# `anvil db migrate` reads ./migrations relative to your working directory, so it only works
# from a checkout of the Anvil repo. From your own app, apply the SQL the package exports:
node --input-type=module -e \
"import {ANVIL_POSTGRES_MIGRATION_SQL} from '@useanvil/sdk';\
import pg from 'pg';\
const c = new pg.Client(process.env.ANVIL_POSTGRES_URL);\
await c.connect(); await c.query(ANVIL_POSTGRES_MIGRATION_SQL); await c.end();"
# 3. Restart your app so it picks up the variable.
Re-run step 2 after an upgrade — migrations are additive and idempotent, so it is always safe. Anvil 0.5.4 added migration 029 (lease_expires_at), which is what lets a stuck idempotency claim be found and reclaimed. A database without it still works: claims are written with a null lease, and the sweep reads that as no lease information rather than expired, so nothing is ever reclaimed by mistake. You just don't get the recovery until you migrate. On Anvil Cloud this is already done — this step is for self-hosted deployments only.
A private host won't work from the dashboard. If you paste a connection string into Check it, Anvil connects from our network, not yours — so a private or link-local address is refused rather than silently timing out. Use the public hostname your app dials from outside, or skip the dashboard path and set the variable by hand.
ANVIL_DATABASE_URL, PGURL and DATABASE_URL are accepted too, so an existing deployment usually needs nothing new.
#Already have a Postgres?
Anvil creates its own tables (anvil_receipts and friends) and touches nothing else, so pointing it at a database you already use is fine. The user in the connection string needs permission to create tables once.
#Checking it worked
Setting the variable is what satisfies the gate — not a working database. On the money path Anvil asks one synchronous question: is a durable adapter installed? The Postgres adapter answers yes as soon as ANVIL_POSTGRES_URL is set, before anything has connected. So a typo'd host clears the gate and then fails at write time, which is later and noisier than failing at boot. Prove the connection rather than assuming it:
npx anvil doctor
Under Auto-instrumentation it will tell you plainly whether the reporting runtime can write durable receipts, and if not, which of three things is wrong:
- No database is configured — set
ANVIL_POSTGRES_URLand restart. - The database can't be reached — check the host, port and credentials.
- The database answers, but Anvil's tables aren't there —
anvil doctorwill tell you to runanvil db migrate. From your own app, run the migration snippet above instead;db migrateonly works from a checkout of the Anvil repo.
Those are three different problems with three different fixes, which is why Anvil names the one you actually have instead of telling you to check everything.
In the dashboard, the same fact appears next to the action: an enforcing action with no durable receipts is labelled, and one that is actively refusing calls because of it says so in red.
#When the database goes down
Anvil keeps going. The durable claim is the second lock, not the only one — Redis still serialises every key, so two retries of the same call still cannot both proceed. A Postgres blip therefore degrades open rather than turning a database incident into a total money-path outage: the call runs on the Redis verdict alone, which is exactly what shipped before the durable claim existed.
What you lose while it is down is the half that survives a restart. Redis is a cache with a TTL, so a retry arriving after a process restart and a Postgres outage may not find either record. That window is the reason the durable claim exists, and it is narrow — but it is the honest answer to "what am I running on right now?"
If you would rather refuse than execute unwitnessed, ANVIL_DURABLE_CLAIM_FAIL_MODE=closed flips it. Most teams should not: refusing every money call during a database blip is usually the worse outage.
Doesn't Redis already do this? No — they are the two halves. Redis coordinates: the idempotency claim, the spend-cap reservation, the workflow counters, all of it live and fast. Postgres remembers: the record that outlives a crash, a restart, or a cache eviction. Redis is what stops two concurrent retries both proceeding; Postgres is what stops a retry an hour later from running again. You need both for money, and Anvil Cloud runs both for you.
#What Anvil does not do
Anvil does not store your connection string. When you paste one into the dashboard it is used to run the checks and the migration, then discarded — which is also why Anvil cannot set the variable on your app for you, and says so rather than showing a "Saved!" that would not be true.
#Once it's set up
You provisioned this to enforce money, so that is the next step: Ghost → Enforce walks the flip with a rehearsed rollback. Confirm the database is actually doing its job first — npx anvil doctor under Auto-instrumentation should say the runtime can write durable receipts, which is the gate the enforce flip checks.