Skip to content

Self-hosting: set up your central service

FloorVote runs on your own Cloudflare account. This page gets your central service running — the one shared worker that pulls in legislative data and feeds it to your teams. Once it's up, you add one or more tenants (a tenant is one team's instance).

It's a guided, one-time setup, and every step is a terminal command you copy and run. You don't need to be a developer, but you should be comfortable running commands in a terminal.

Accounts you'll need

Set up two accounts before you start:

  • Cloudflare — where FloorVote runs. You'll need the Workers Paid plan ($5/month): the Free plan can't send email to your members, and FloorVote uses magic-link authentication, so email is the only way anyone can log in. The Free plan also covers only very light traffic — enough for a demo, not a working deployment.
  • LegiScan — where the bill data comes from. Register for a LegiScan account here, confirm it, then follow their steps to generate an API key here. The free tier should be enough for many deployments, but heavier API users (what does that mean?) might need a paid LegiScan API subscription.

IMPORTANT

LegiScan provides the bill data through their API but is not involved with FloorVote. If you sign up for LegiScan, please don't contact them for help with FloorVote.

NOTE

FloorVote also contains some code for interfacing with the OpenStates API as an alternative to LegiScan, but this code path is not actively maintained and lacks features present in the LegiScan path.

Cloudflare API tokens

You'll use two kinds of Cloudflare API token. Knowing the difference up front makes the rest of this guide clearer:

  • One broad deploy token, kept on your own machine. This is what wrangler (Cloudflare's command-line tool, used throughout this guide) uses to create resources and deploy workers. It lives in your shell as CLOUDFLARE_API_TOKEN.
  • A few narrow runtime tokens, which the deployed worker itself uses for the handful of Cloudflare APIs it calls — for example, delivering bills to each team's queue. Each is stored as a worker secret, scoped to only what it needs. You create these at the steps that use them (the first is CF_QUEUES_TOKEN, below).

WARNING

Save each token as you create it. Cloudflare shows a token's value only once. Paste each one into a password manager or a secure note the moment you create it — that's your record of your deploy token and each runtime token.

Create the deploy token

  1. In the Cloudflare dashboard, go to My Profile → API Tokens → Create Token.
  2. Start from the "Edit Cloudflare Workers" template. It already covers almost everything — you only need to Add three more permissions. Each is a row with three fields (group, resource, access):
    • Account / D1 / Edit
    • Account / Queues / Edit
    • Zone / DNS / Edit — lets you put teams on custom domains later
  3. Create the token, copy it, and add it to your shell so wrangler uses it automatically:
bash
# in ~/.zshrc or ~/.bashrc — API tokens don't expire
export CLOUDFLARE_API_TOKEN="..."

(Alternatively, wrangler login signs in through your browser, but that session expires periodically.)

Prerequisites

  • A Cloudflare account on the Workers Paid plan, with the deploy token above exported in your shell.
  • R2 storage enabled on that account — a one-time step: in the dashboard, go to R2 → Overview and complete the subscription checkout. It has a free tier, but you can't create a bucket until it's activated.
  • wrangler installed: npm install -g wrangler. Commands below are written as bare wrangler, which uses that global install. You can instead prefix every command with npx to use the version pinned in the repository's package.json — which is what the project's own scripts (new-instance.sh, deploy.sh) do. Either works; pick one and stay with it, since a global wrangler and a pinned one can differ enough in config handling to make a resource you created one way invisible to a script run the other.
  • The repository cloned, with dependencies installed (npm install from the repo root, and once inside central/).

How it fits together

  • Central service — one per operator. It makes all the legislative API calls and stores all the bill data, so provider traffic, caching, and quota all sit in one place however many teams you run. You set this up on this page.
  • Tenant workers — one per team or topic. Each has its own users, votes, comments, and positions, and never calls LegiScan directly. You add these on the next page.

Set up the central service

1. Provision Cloudflare resources

Run from the repository root:

bash
wrangler d1 create central-bills-ls --location enam
wrangler r2 bucket create central-bill-texts-ls
wrangler queues create central-legiscan-ingestor

Save the database_id from the D1 output — you'll need it in the next step.

IMPORTANT

The -ls suffix is required, not stylistic. npm run deploy:legiscan applies migrations to a database named exactly central-bills-ls. The unsuffixed names belong to the OpenStates path, so the two providers can coexist on one account without colliding.

TIP

--location sets the D1 primary region, and it defaults to wherever you are when you run the command — not where your users are. Pick the hint nearest your audience (enam, wnam, weur, eeur, apac, oc). Getting this wrong adds a cross-ocean round trip to every query, and changing it later means recreating the database and reseeding.

2. Configure central/wrangler.toml

Copy central/wrangler.example.toml to central/wrangler.toml and fill in your values. The key settings for a LegiScan deployment:

toml
[env.legiscan]
name = "floorvote-central-legiscan"
main = "src/index-legiscan.ts"

[env.legiscan.vars]
# optional — shown as a credit in each team's footer
OPERATOR_NAME = "Your Organization Name"
BILL_PROVIDER = "legiscan"

[[env.legiscan.d1_databases]]
binding = "DB"
database_name = "central-bills-ls"
database_id = "<YOUR_D1_DATABASE_ID>"
migrations_dir = "migrations-legiscan"

[[env.legiscan.r2_buckets]]
binding = "BILLS_BUCKET"
bucket_name = "central-bill-texts-ls"

[[env.legiscan.queues.producers]]
binding = "INGESTOR_QUEUE"
queue = "central-legiscan-ingestor"

[[env.legiscan.queues.consumers]]
queue = "central-legiscan-ingestor"
max_batch_size = 5
max_batch_timeout = 30

[env.legiscan.triggers]
crons = ["0 * * * *"]

See central/wrangler.example.toml for the complete template, including optional bindings.

TIP

To show your organization's logo in the app, add a file named exactly web/public/operator-logo.svg and rebuild. If it's absent, no logo renders (and a harmless 404 for /operator-logo.svg in the browser network log is expected).

3. Set central secrets

From inside central/. First generate a strong ADMIN_SECRET — copy the output, you'll paste it at the prompt in a moment, and save it to your password manager:

bash
openssl rand -base64 32

Then set both secrets. Each command prompts for the value:

bash
# Paste the openssl output above at this prompt — it guards the admin endpoints
wrangler secret put ADMIN_SECRET --env legiscan

wrangler secret put LEGISCAN_API_KEY --env legiscan

NOTE

You haven't deployed yet, so the Worker doesn't exist — wrangler will ask whether to create one with this name and add the secret to it. Answer yes. That's the expected path when setting secrets before the first deploy.

Queue delivery

Central delivers bills to each team's queue. For that it needs a Cloudflare API token scoped Queues: Edit, plus central's account ID:

bash
wrangler secret put CF_QUEUES_TOKEN --env legiscan

Also set CF_ACCOUNT_ID in [env.legiscan.vars] (it's not a secret — get it from wrangler whoami). Without both, a team can register cleanly yet never receive a single bill, with no error. You can reuse your broad token from above, or create a narrow Queues: Edit token at My Profile → API Tokens → Create Custom Token.

TIP

Central also offers an optional superadmin dashboard and observability panels. They're not needed to get running — see Operating your deployment.

4. Run migrations and deploy

bash
cd central
npm ci
npm run deploy:legiscan

NOTE

Use npm ci, not npm install. The repository ships a lockfile; npm ci installs exactly those versions, while npm install re-resolves against whatever has been published since and can fail on a peer-dependency conflict that has nothing to do with your setup.

The deploy script runs migrations for you. The deployed URL will be https://floorvote-central-legiscan.<your-subdomain>.workers.dev — note it, you'll use it as CENTRAL_API_URL when adding tenants.

5. Verify

bash
curl https://floorvote-central-legiscan.<your-subdomain>.workers.dev/api/health
# → {"status":"ok","operator":"Your Organization Name"}

Your central service is now running. From here it will sync live bill data on an hourly schedule.

Optional: preload historical bills now

If you already know which sessions your teams will track — say, only New Jersey, or all 50 states plus Congress — you can load that historical bill data into central now, so it's ready the moment you add a tenant. This is optional: you can skip it and bring bills in when you add a tenant instead (the tenant guide walks through seeding a session).

Load LegiScan's bulk JSON datasets — this uses zero API calls. Download the zip files from legiscan.com/gaits/datasets (one per state/session), extract them, then run the seeder for each session:

bash
# LegiScan zips have a nested state/session/ directory inside. Unzip, then point
# --from-dir at the folder that contains the bill/ subdirectory:
unzip -q RI_2026-*.zip -d bulkseeds/RI_2026
find bulkseeds/RI_2026 -name "bill" -type d
# → bulkseeds/RI_2026/RI/2026-2026_Regular_Session/bill

npx tsx scripts/seed-legiscan.ts \
  --from-dir bulkseeds/RI_2026/RI/2026-2026_Regular_Session \
  --state RI \
  --session-id 2253 \
  --remote

This loads the bills into central only. Linking them to a team and running AI summaries happens when you add the tenant.

CAUTION

Speed: about 1,000 bills per minute against the remote database, so a typical state session (500–3,000 bills) takes 1–5 minutes. There's a --from-api option that downloads the dataset for you, but it can run out of memory on very large states (16,000+ bills) — use the manual download with --from-dir for those.

Next steps

IMPORTANT

Using LegiScan data: your API plan sets a monthly call budget, which central tracks against the legiscan_monthly_limit setting — see How much does it cost? for the tiers. Bill data is licensed CC BY 4.0, so any UI that displays it carries a data-source credit (FloorVote does this by default). Deeper LegiScan operational notes are in docs/internal/legiscan-notes.md in the repository.

Development supported by the Bipartisan Policy Center.Architecture and security have been reviewed and strengthened through a volunteer engagement with U.S. Digital Response.Bill data comes from LegiScan, licensed CC BY 4.0; summaries and relevance scores are generated by FloorVote. FloorVote reads legislative data through a provider interface, and LegiScan is the maintained implementation.FloorVote is open source under AGPL-3.0.