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 -dMW_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:
| Service | Port | Image |
|---|---|---|
web | 3000 | ghcr.io/matchwire/matchwire |
backend | 8080 | ghcr.io/matchwire/matchwire-backend (/mcp, /v1/*) |
worker | — | ghcr.io/matchwire/matchwire-backend (dist/worker.js) |
migrate | — | ghcr.io/matchwire/matchwire-backend (one-shot, exits 0) |
db | — | paradedb/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 migrateThe 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.sqlUpgrading 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:
| Feature | SMTP unset | SMTP 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.comWhile 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 -dThe 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_URLto 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 needANTHROPIC_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 URIhttps://<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.freebusyscope and setMW_CONNECTOR_GOOGLE_CALENDAR_CLIENT_ID/MW_CONNECTOR_GOOGLE_CALENDAR_CLIENT_SECRET(redirect URIhttps://<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.