Self-hosting

Run the full matchwire product yourself with Docker Compose.

matchwire is AGPL-3.0 open core and designed to be self-hosted. The compose stack under docker/ runs the entire product — database, migrations, web app, API/MCP backend, and background worker — from the same signed images the managed service deploys (ADR-0036).

Quickstart

Prerequisites: Docker with the Compose plugin, and (for AI features) your own model API keys.

git clone https://github.com/matchwire/matchwire.git
cd matchwire/docker
cp .env.example .env    # set MW_AUTH_SECRET_CANDIDATE / MW_AUTH_SECRET_STAFF + model keys
docker compose up -d

MW_AUTH_SECRET_CANDIDATE and MW_AUTH_SECRET_STAFF are required — compose refuses to start without them, so the dev fallback auth secrets can never reach a deploy. Everything else in .env.example is optional and degrades gracefully: the stack boots without model keys, but matching/search embeddings need OPENAI_API_KEY and the agent/negotiation features need ANTHROPIC_API_KEY.

What runs where:

ServicePortImage
web3000ghcr.io/matchwire/matchwire
backend8080ghcr.io/matchwire/matchwire-backend (/mcp, /v1/*)
workerghcr.io/matchwire/matchwire-backend (dist/worker.js)
migrateghcr.io/matchwire/matchwire-backend (one-shot, exits 0)
dbparadedb/paradedb (Postgres + pgvector + pg_search)

Each app service also carries a build: block, so docker compose up -d --build builds everything from source instead of pulling GHCR.

Migrations

The one-shot migrate service applies the drizzle migrations before any app container starts — the exact command the managed deploy runs as its pre-deploy step. Re-run it manually any time:

docker compose run --rm migrate

The optional BM25 arm of hybrid search needs one extra index (ParadeDB pg_search, not part of the standard migration chain — see packages/db/paradedb/README.md):

docker compose exec -T db psql -U matchwire -d matchwire \
  < ../packages/db/paradedb/0001_bm25_index.sql

Upgrading from the pre-MW-96 stack: the database image changed from pgvector/pgvector to ParadeDB. That is not an in-place upgrade — recreate the volume (docker compose down -v) and start fresh (pre-1.0, no data migration path).

Bootstrap

Sign-up never auto-creates an organization: bootstrap the organization AND your first staff admin in one command. From docker/, with the stack up:

# Creates the organization (when none exists), your person, the admin membership,
# and your STAFF account — the initial password is printed exactly once.
docker compose run --rm backend pnpm --filter @matchwire/db bootstrap-admin -- \
  --email you@example.com --org-name "Acme Inc"

Sign in at http://localhost:3000/org/signin with that email + the printed password. First sign-in is a two-step onboarding on a dedicated setup screen: change the initial password, then register TOTP — staff two-factor is enforced by default for new organizations (an org admin can opt out on 組織, or waive it right on that setup screen). From there everything is self-serve in the org settings UI: invite your hiring team on メンバー (each invitation prints a one-time URL — hand it to the invitee; with SMTP configured it is also mailed), adjust the organization-wide two-factor requirement on 組織, and manage TOTP / passkeys for your own account on セキュリティ設定.

Candidates sign THEMSELVES up at http://localhost:3000/signin (email + password) — the two realms are separate accounts by design (ADR-0098), so a recruiter who is also a job seeker holds one of each, and neither password may equal the other.

Two optional follow-ups:

Approver capability — human-approval workflows (AI write actions) need at least one approver. The grant takes the person's subject_token, which you can look up from the person id printed by bootstrap-admin:

docker compose exec db psql -U matchwire -d matchwire -tA \
  -c "select subject_token from persons where id = '<person-id>'"
docker compose run --rm backend pnpm --filter @matchwire/db grant-approver -- \
  --organization <org-id> --approver <subject-token>

MCP credential — mint a mw_sk_* bearer secret for your AI agent (printed once; only its sha256 is stored):

docker compose run --rm backend pnpm --filter @matchwire/db mint-credential -- \
  --organization <org-id>

Send it as Authorization: Bearer <secret> against http://localhost:8080/mcp (MCP) and the http://localhost:8080/v1/* REST mirrors, or export it as MW_API_KEY for the stdio MCP entrypoint.

Authentication

Human auth is two disjoint better-auth realms (MW-255, ADR-0098): the candidate realm (open sign-up at /signin) and the invite-only company-staff realm (/org/signin, 12-hour absolute sessions). Employer surfaces demand a staff session — a signed-in candidate hitting /candidates is redirected to the staff sign-in, structurally (上司ブラインド).

SMTP is optional. Everything works with email + password alone. Setting MW_SMTP_URL + MW_SMTP_FROM activates the mail-dependent flows:

FeatureSMTP unsetSMTP set
Candidate email+password sign-up/in✓ (sign-up requires email verification)
Candidate magic-link sign-in— (endpoint off)
Password reset (/reset-password)— (endpoint off)
Same-email OAuth auto-link (Google/LinkedIn)— (password users stay unverified, so OAuth with the same email is always refused with account_not_linked)✓ (once the address is verified)
Staff invitations✓ one-time URL in the admin UI✓ URL + invitation mail
Staff TOTP two-factor / passkeys

Behind a TLS-terminating proxy, also set BETTER_AUTH_URL to the public origin so emailed links, OAuth redirects, and passkey origins resolve correctly.

Two-factor enforcement: an org admin can require TOTP for all staff (組織 → スタッフの2要素認証). Staff without a registered TOTP are then held on a dedicated onboarding screen until they finish — including the admin who flipped the switch. An admin held there can also waive the requirement from that screen, so flipping the switch before registering your own TOTP is recoverable.

Lost passwords without SMTP (no self-service reset): the operator deletes the credential row and the person re-onboards — profile data is anchored on the person and survives.

# Candidate: delete the auth account; they sign up again with the same email.
docker compose exec db psql -U matchwire -d matchwire \
  -c "delete from candidate_auth_users where email = 'person@example.com'"

# Staff: delete the staff account, then send a fresh invitation from
# /org/members (their membership and role are untouched).
docker compose exec db psql -U matchwire -d matchwire \
  -c "delete from staff_auth_users where email = 'person@example.com'"

Notifications

Candidates can receive reach notifications (scouts, mutual interest, interview schedule moves, selection-stage changes, a 15-minute message digest) outside the app, toggleable per kind in the settings modal's notifications tab (MW-596). Both channels are optional and degrade silently when unconfigured — no dead rows. An unconfigured email channel shows its column as honestly disabled; an unconfigured push channel is simply absent from the notifications tab.

Email rides the same SMTP configuration as auth mail: set MW_SMTP_URL + MW_SMTP_FROM. Optionally set MW_PUBLIC_WEB_URL (the public web origin) so notification mails carry a landing link — without it the mails simply have no link line.

Web Push needs a VAPID key pair. Generate one and inject all three variables into the web AND worker services:

npx web-push generate-vapid-keys
# MW_VAPID_PUBLIC_KEY=...
# MW_VAPID_PRIVATE_KEY=...
# MW_VAPID_SUBJECT=mailto:ops@example.com

While the three variables are unset, the settings panel does not render the push column at all — the notifications table shows the email column only.

Subscribing is always an explicit per-browser action on the settings panel (the app never auto-prompts for notification permission). Expired subscriptions (push service answers 410/404) are disabled automatically and recover when the person re-subscribes from that browser.

iOS honesty note: Web Push on iOS requires iOS 16.4+ AND the app installed to the home screen (a standalone PWA) — Safari tabs do not receive pushes. The settings panel says so; there is no workaround.

Background worker

The worker service runs background jobs (search reindexing, LLM classification, imports, notification fan-out, cron scans) with an explicit concurrency of 5 by default. Tune it with MW_WORKER_CONCURRENCY (optional, integer 1–10): lower it on small databases, raise it toward 10 if jobs queue up. The range is bounded by the default connection pools (10 each); invalid values make the worker fail at startup instead of silently falling back.

Upgrades

Pin MW_IMAGE_TAG in .env to a release tag (recommended over latest), then:

docker compose pull && docker compose up -d

The migrate service re-runs the (idempotent) migrations before the apps restart. Release images are cosign-signed with SBOM + provenance attestations (ADR-0040), so you can verify what you run before pulling it.

Managed Postgres

Set DATABASE_URL in .env to point every service (including migrate) at your own database. One caveat: plain Postgres + pgvector works; only the opt-in BM25 arm of hybrid search needs ParadeDB's pg_search.

Limitations (honest edition)

  • Plain HTTP: the stack terminates HTTP — put a TLS reverse proxy in front for anything non-local and set BETTER_AUTH_URL to the public origin. Application auth itself is real (two-realm better-auth, ADR-0098), including organization-IdP SSO login over OIDC and SAML; enterprise governance (SSO enforcement, SCIM) stays a separate track (MW-143).
  • No SaaS control plane: no provisioning, no billing (Stage 3).
  • Bring your own model keys: matching/search embeddings need OPENAI_API_KEY; agent features need ANTHROPIC_API_KEY. Without them the stack runs, but those features stay dormant.
  • Observability (Langfuse), object storage (S3), and cache/rate limiting (Upstash Redis) are env-pointed opt-ins, not bundled services.
  • Import source connectors (Google Drive / Notion) are an env-pointed opt-in too: register your own OAuth clients and set the MW_CONNECTOR_* pairs (redirect URI https://<host>/org/connections/<provider>/callback); unset means the picker lane stays hidden. A connection is organization-wide — every member holding the org's job-editing permission can search whatever the connected account can read, so connect a recruiting-dedicated account or shared drive rather than a personal one.
  • The candidate calendar overlay (Google Calendar, free/busy only) is its own opt-in: register a separate OAuth client requesting only the calendar.freebusy scope and set MW_CONNECTOR_GOOGLE_CALENDAR_CLIENT_ID/MW_CONNECTOR_GOOGLE_CALENDAR_CLIENT_SECRET (redirect URI https://<host>/settings/connections/google-calendar/callback); unset keeps the calendar lane hidden. Under a managed Google Workspace that restricts third-party apps, your IT admin must allow this deployment's client before members can connect.

Local development

Contributors run the app natively instead (HMR — ADR-0055):

just install # deps + .env seeded from .env.example (dev servers auto-load it)
just up      # long-lived infra: ParadeDB Postgres + Langfuse (+ClickHouse) + Redis (Valkey) + MinIO
just dev     # DB migrations auto-applied, then app natively with HMR (or: pnpm dev)

See the repository README and the Architecture Decision Records under docs/decisions/ for the full picture.

On this page