Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

Contributing to Hardcount

Hardcount is a billing system you run yourself, written in Go on PostgreSQL. It counts what your customers use, works out each bill, makes the invoice, and charges the card through Stripe.

This page takes you from a fresh clone to a pull request that passes CI. It shows how to run Hardcount from source, where the code goes, and which checks CI runs on your change.

You need Go 1.26 or newer and Docker. Node 22 or newer is needed only for dashboard work, and jq only for scripts/demo.sh.

Terminal window
git clone https://github.com/gethardcount/hardcount.git
cd hardcount
cp .env.example .env
  1. Start Postgres.

    Terminal window
    docker compose up -d postgres

    You see: docker compose ps lists the postgres service as healthy after a few seconds.

  2. Create your first account and its keys. This also runs the database migrations.

    Terminal window
    make bootstrap

    You see: a “Hardcount Bootstrap Complete” box with the dashboard sign-in (email admin@hardcount.local unless you set HARDCOUNT_BOOTSTRAP_EMAIL), a generated password, and three API keys. Save the password: it is shown only once. Copy the key under “Secret Key — TEST mode” (hc_secret_test_…).

  3. Start the API. make dev reads .env, which points at the local Postgres and runs migrations on boot.

    Terminal window
    make dev

    You see: a JSON log line with "msg":"listening" and "addr":":8080". curl http://localhost:8080/health returns {"status":"ok"}.

  4. Start the dashboard, in a second terminal.

    Terminal window
    cd web-v2 && npm install && npm run dev

    You see: Vite prints http://localhost:5173/. Open http://localhost:5173/login and sign in with the email and password from step 2. The dashboard opens in test mode, where nothing charges real money.

  5. Bill a test customer end to end, in a third terminal. This is the golden path: the one flow CI also runs on every PR, from loading prices to a finished invoice.

    Terminal window
    ./scripts/demo.sh hc_secret_test_... # the key from step 2

    You see: nine numbered steps, each with a green ✓ line, then “Done: every step above ran and was checked” and two dashboard links, one to the invoice and one to the customer.

Run the tests with Postgres still up:

Terminal window
go test ./... -race -short -count=1 # unit tests (CI runs -race too)
go test -p 1 ./... -count=1 -short=false # unit + integration tests

To try a release without Go or Node, use deploy/compose/quickstart.sh instead (see the README).

docker compose up -d postgres redis mailpit starts everything the API can use on your machine:

Service Where Notes
Postgres :5432 Password hardcount. Data is kept in a Docker volume.
Redis :6379 Used for rate limiting. Optional outside production: without it, only sign-in (/v1/auth) is rate-limited.
Mailpit :1025 (SMTP), :8025 (web) Catches every email Hardcount sends, so nothing leaves your machine.
The API :8080 Started by make dev. Not a container.

make bootstrap takes an optional owner email and password: HARDCOUNT_BOOTSTRAP_EMAIL=you@example.com HARDCOUNT_BOOTSTRAP_PASSWORD=change-me-please1 make bootstrap. A password needs at least 12 characters, and Hardcount refuses common passwords from leaked-password lists.

One Hardcount server can hold many accounts (tenants), each with its own data, dashboard users and API keys. To add another one, run the bootstrap tool again with a different owner email:

Terminal window
make bootstrap HARDCOUNT_BOOTSTRAP_EMAIL=tenant-b@hardcount.local \
HARDCOUNT_BOOTSTRAP_PASSWORD='choose-a-password' \
HARDCOUNT_BOOTSTRAP_TENANT='Tenant B'

The tool runs from source, so it needs Go and this repository. It is not in the Docker image. There is no button for this in the dashboard, and a secret key cannot do it: only a platform key can manage accounts, and a standard Hardcount server issues none.

Never git stash in a repo shared by several worktrees. The stash belongs to the whole repo, and a pop can land another worktree’s work in your tree.

These are the same steps without make. make dev reads .env, which sets DATABASE_URL and RUN_MIGRATIONS_ON_BOOT=true. Here you set them on the command line:

Terminal window
docker compose up -d postgres
DATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" go run ./cmd/hardcount-bootstrap
DATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" RUN_MIGRATIONS_ON_BOOT=true go run ./cmd/hardcount

To run only the migrations:

Terminal window
DATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" go run ./cmd/hardcount migrate

This builds the API image from your checkout and runs it in the same stack as a one-VM install:

Terminal window
docker build -t hardcount:local . # from the repo root
cd deploy/compose
cp .env.example .env
$EDITOR .env # set POSTGRES_PASSWORD, HARDCOUNT_APP_DB_PASSWORD, HARDCOUNT_ENCRYPTION_KEY, HARDCOUNT_BOOTSTRAP_TOKEN
HARDCOUNT_IMAGE=hardcount:local docker compose up -d

You see: docker compose logs -f hardcount-api prints a log line with "msg":"listening" and "addr":":8080" when the API is ready.

To build the image and run it on its own, against a Postgres you already have:

Terminal window
docker build -t hardcount:latest .
docker run --rm -e DATABASE_URL="..." -p 8080:8080 hardcount:latest

Each billing domain (invoice, credit, customer, and so on) is a self-contained package under internal/. Architecture shows how the packages work together.

internal/{domain}/
store.go — Store interface (what the domain needs from persistence)
postgres.go — PostgreSQL implementation of the store
service.go — Business logic (validates, orchestrates)
handler.go — HTTP handlers (decodes request, calls service, writes response)
*_test.go — Unit tests (in-memory store) + integration tests (real Postgres)

Rules:

  • One domain package never calls another domain’s Service or Store directly. A package may import shared value types, cross-cutting infrastructure (auth, audit, session), and the small interfaces of the billing coordinator. The allowlist in internal/arch/boundaries_test.go enforces this: a new cross-domain import fails the test until it is added there on purpose.
  • Every handler uses respond.JSON() / respond.FromError() for responses.
  • Most store methods run inside a transaction scoped to one tenant by Postgres Row-Level Security (RLS): db.BeginTx(ctx, postgres.TxTenant, tenantID). A tenant is one account’s data in a shared database; RLS hides every other tenant’s rows.
  • Some store methods run under postgres.TxBypass, where RLS does not apply: work across tenants, work before the tenant is known, and two signed-in areas. Such a query must filter by tenant_id itself. Architecture lists them. A new use fails internal/arch/txbypass_allowlist_test.go until it is listed there.
  • Integration tests use testutil.SetupTestDB().
  1. Create internal/{domain}/store.go with the store interface.
  2. Create internal/{domain}/postgres.go implementing the store.
  3. Create internal/{domain}/service.go with business logic.
  4. Create internal/{domain}/handler.go with HTTP handlers using the respond package.
  5. Add a migration with make migration name=..., then follow Writing a database migration.
  6. Wire it into internal/api/router.go.
  7. Write tests at the layers How we test names for your change.
  • go fmt and go vet must pass.
  • No exported types without doc comments.
  • Errors use errs.DomainError for domain-specific errors or fmt.Errorf for validation.
  • JSON field names are snake_case.
  • ID format: hc_{type}_{random_hex} (for example hc_cus_abc123).

The dashboard is a React + TypeScript + Vite app. See web-v2 — the Hardcount dashboard for setup, the dev server, and the directory’s own CI checks. Two things often surprise first-time contributors:

  • npm install relies on web-v2/.npmrc. That file tolerates a peer-dependency mismatch on purpose, so do not “fix” the mismatch.
  • Typechecking is npx tsc -b. Do not use tsc --noEmit: here it checks nothing and reports no error.

CI runs every check below on every PR. Run the ones your change touches locally before you push. Fork PRs get the same run, because no check uses secrets.

Required to merge. A PR cannot merge until each of these passes.

CI job What it checks Local command
unit Unit tests with the race detector, then go vet. Includes the source-rule tests in internal/arch go test ./... -race -short -count=1
test Build, module checksums, and the integration tests against a real Postgres go test -p 1 ./... -count=1 -short=false (needs docker compose up -d postgres; test databases are created for you)
test-pgbouncer The same suite through PgBouncer, a connection pooler, in transaction mode scripts/pgbouncer-test.sh (needs Docker)
golden-path Boots a real server, runs scripts/demo.sh from prices to a finished invoice, then runs hardcount-doctor over the rows it wrote Steps 2 to 5 of Development setup, then go run ./cmd/hardcount-doctor
lint golangci-lint (.golangci.yml), the Go version in go.mod against the Dockerfile, the quickstart release against the Compose template, and govulncheck make lint, then govulncheck ./...
codegen-drift Generated Go and TypeScript match api/openapi.yaml make gen && git diff --exit-code
frontend Dashboard build (tsc -b and Vite), unit tests, eslint, and knip (unused files, exports and dependencies) cd web-v2 && npm run build && npm test && npm run lint

Also run, not required to merge.

CI job What it checks Local command
e2e Playwright tests against a real API and dashboard. Setup: Browser tests (Playwright) cd web-v2 && npm run test:e2e
doc-links Every internal Markdown link and #anchor resolves. It turns red on a broken link; fix it before merging lychee --offline --include-fragments '**/*.md'
doc-style Vale prose lint on the lines your PR adds. It comments only and never fails vale <changed .md files>

Notes on three of them:

  • hardcount-doctor is a read-only command that scans a database for states no correct code could produce, such as invoice totals that do not add up. Point DATABASE_URL at the owner connection: under the app’s RLS role it sees no rows and reports clean.
  • govulncheck can fail when a new Go vulnerability is published, even if your change is fine. Say so in the PR and we will handle it.
  • Lint waivers and source rules (the //nolint:forbidigo reasons and the //tz:ok date rule) are described in How we test.

Anyone can contribute. We judge the change, not the person. To keep review time for real work, every pull request meets this bar:

  1. Start with an issue. For anything bigger than a typo or a broken link, open an issue first, or comment on an existing one. Wait until a maintainer agrees with the approach, then link that issue in your pull request. A pull request without an agreed issue is closed without review.
  2. Some areas are maintainer-only. A change in these areas needs an agreed issue that says an outside pull request is welcome:
    • money and state machines: invoices, payments, credits, dunning, subscriptions and tax;
    • database migrations;
    • sign-in, sessions and API keys;
    • CI and release files (.github/);
    • dependencies (go.mod, web-v2/package.json and the lock files).
  3. One change per pull request, with its tests and docs, and CI green. Unrelated edits, such as formatting sweeps, renames or bulk dependency bumps, are closed.
  4. Show that you ran it. Say what you changed, why, and how you checked it. A pull request that was not run or understood is closed.

What happens next: CI on a pull request from outside the project waits until a maintainer has read the diff and approved the run. We answer within two days. A pull request below the bar gets one short comment and is closed; you are welcome to open an issue and try again.

  1. Fork the repo and create a feature branch.
  2. Write tests for new behavior, at the layer How we test names for your change.
  3. Run the checks above that your change touches (at least the two test commands).
  4. Update the docs in the same PR. Every user-visible change gets a CHANGELOG.md entry, and every UI change is covered by a Playwright spec in web-v2/e2e/. A doc that no longer matches the code is a bug. For how to write docs, see the writing style guide.
  5. Open the PR with a clear description of what changed and why.

Touching money or a state machine? This covers invoices, payments, credits, dunning (retrying a failed card payment and emailing the customer), subscriptions and tax. Follow the Money-path robustness playbook:

  • Before opening the PR, work through its implementation checklist (§3).
  • In the PR description, list the state’s site-set (§2): every place in the code that writes that state or reacts to it.
  • A guard against a race or a broken money rule needs an automated test. The test forces the race, and you show it fails once the guard is removed (§5).

Words used here that you have not met are in the glossary.