Repository navigation
Deployment
Job Matcher is deployed as a Docker Compose stack on a single host: the Next.js web app, the NestJS API, the BullMQ run worker, a one-off migration job, PostgreSQL 17 and Redis 8. Two images are built from one multi-stage Dockerfile: job-matcher-api (used by the API, the worker and the migration job) and job-matcher-web. Only the web container publishes a port; it proxies /api to the API over the Compose network. This page describes the images, the Compose files, networking and HTTPS, backups, upgrades, uninstalling and resource needs. For the quick install, see Getting Started; for every setting, see Configuration Reference.
flowchart LR
user([Browser]) -- "WEB_PORT (default 8080)" --> web["web<br/>job-matcher-web, :3000"]
web -- "/api/* rewrite to http://api:4000" --> api["api<br/>job-matcher-api, :4000"]
api --> pg[("postgres<br/>postgres:17")]
api --> redis[("redis<br/>redis:8")]
worker["worker<br/>job-matcher-api, bun src/worker.ts"] --> pg
worker --> redis
migrate["migrate (one-off)<br/>prisma migrate deploy"] --> pg
worker -. live mode .-> ext["Firecrawl, OpenRouter, Discord"]
Both images build from the repository root. The file has three stages: a shared base and two final targets, api and web, which each build FROM base.
| Instruction | Effect |
|---|---|
FROM oven/bun:1.4 AS base |
Official Bun image |
LABEL |
org.opencontainers.image.source, description and licenses (MIT); GHCR links the package to the repository through source
|
WORKDIR /app |
|
COPY package.json bun.lock tsconfig.base.json prisma.config.ts ./ and each workspace's package.json
|
Manifests first, so the install layer is cached until dependencies change |
RUN bun install --frozen-lockfile --ignore-scripts |
Installs every workspace's dependencies, including dev dependencies. Lifecycle scripts are skipped because prepare runs git config, which needs a git checkout |
COPY prisma prisma, COPY packages packages
|
Schema, migrations and the shared package |
RUN DATABASE_URL=postgresql://build@localhost/build bunx --bun prisma generate |
Generates the Prisma client into apps/api/src/generated/prisma. The URL is a placeholder: prisma.config.ts requires it, but generation never connects |
| Instruction | Effect |
|---|---|
COPY apps/api apps/api |
The API source, including test/fixtures/, which demo mode reads at runtime |
ENV NODE_ENV=production |
|
WORKDIR /app/apps/api |
Bun reads tsconfig.json (decorator settings) from the working directory |
USER bun |
Runs as the image's unprivileged bun user |
EXPOSE 4000 |
|
CMD ["bun", "src/main.ts"] |
Runs the TypeScript source directly; there is no compile step |
The worker uses the same image with command: ["bun", "src/worker.ts"], and the migration job with bunx --bun prisma migrate deploy in /app.
| Instruction | Effect |
|---|---|
COPY apps/web apps/web |
|
ARG API_URL=http://api:4000 |
Next.js resolves rewrites at build time, so the API address is fixed when the image is built |
ENV API_URL=$API_URL NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 |
|
RUN bun run --cwd apps/web build && chown -R bun:bun apps/web/.next |
Production build; .next is handed to the bun user because next start writes to it |
USER bun |
|
EXPOSE 3000 |
|
CMD ["bun", "run", "--cwd", "apps/web", "start"] |
next start on port 3000 |
- Both images contain the full workspace
node_modulesfrombase, including dev dependencies and the other app's dependencies, plusprisma/,packages/sharedand the generated Prisma client. Theapiimage has no Next.js build; thewebimage has no API source. - Neither image defines a Docker
HEALTHCHECK. Health checks are defined indeploy/compose.yml(below). - Both run as the non-root
bunuser.
.dockerignore keeps these out of the build context: **/node_modules, **/.next, .git, .env and .env.* (except .env.example), test-results, playwright-report and coverage. Local secrets and build output therefore never reach an image.
deploy/compose.yml runs the published images. Its project name is fixed as job-matcher, so volumes are named job-matcher_postgres-data and job-matcher_redis-data wherever the file lives. Secrets come from .env next to the file.
The migrate, api and worker services share an x-api anchor:
image: ghcr.io/karthik-beta/job-matcher-api:${JOB_MATCHER_VERSION:-latest}env_file: .env-
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/job_matcherandREDIS_URL=redis://redis:6379, overriding anything in.env restart: unless-stopped-
depends_on:migratecompleted successfully andredishealthy
| Service | Image | Command | Healthcheck | Depends on | Ports | Volumes | Restart |
|---|---|---|---|---|---|---|---|
postgres |
postgres:17 |
default |
pg_isready -U postgres -d job_matcher, every 5 s, 20 retries |
none | none | postgres-data:/var/lib/postgresql/data |
unless-stopped |
redis |
redis:8 |
redis-server --appendonly yes |
redis-cli ping, every 5 s, 20 retries |
none | none | redis-data:/data |
unless-stopped |
migrate |
api image |
bunx --bun prisma migrate deploy (working dir /app) |
none |
postgres healthy |
none | none | no |
api |
api image | bun src/main.ts |
bun -e "fetch('http://localhost:4000/api/health')", exits 0 on HTTP 2xx; every 10 s, 10 retries |
migrate completed, redis healthy |
none | none | unless-stopped |
worker |
api image | bun src/worker.ts |
none |
migrate completed, redis healthy |
none | none | unless-stopped |
web |
ghcr.io/karthik-beta/job-matcher-web:${JOB_MATCHER_VERSION:-latest} |
next start |
none |
api healthy |
${WEB_PORT:-8080}:3000 |
none | unless-stopped |
Start-up order: postgres healthy, then migrate runs to completion, then api and worker start (with redis healthy), then web starts once api is healthy. docker compose up --wait returns once the services are running and those with a healthcheck report healthy; CI's docker job relies on this.
The file sets no CPU or memory limits. The postgres password is required: Compose stops with "Set POSTGRES_PASSWORD in .env" if it is empty.
Every docker compose up runs migrate, which applies any pending migrations from the image's prisma/migrations with prisma migrate deploy and exits. It never creates new migrations and is a no-op when the database is current. If it fails, api and worker do not start; check docker compose logs migrate.
deploy/compose.build.yml is an override that adds build sections, so the images are built from the checkout instead of pulled:
| Service | Build |
|---|---|
migrate, api, worker
|
context: .., target: api
|
web |
context: .., target: web, args: { API_URL: http://api:4000 }
|
git clone https://github.com/Karthik-beta/AI-Job-Resume-Matcher.git && cd AI-Job-Resume-Matcher
cp deploy/.env.example deploy/.env # fill in the Required section
docker compose -f deploy/compose.yml -f deploy/compose.build.yml up -d --build --waitBuilt images keep the image: names from compose.yml, so they are tagged ghcr.io/karthik-beta/job-matcher-*:<JOB_MATCHER_VERSION> locally.
.env next to compose.yml holds every setting; the template is deploy/.env.example.
| Section | Variables |
|---|---|
| Required |
POSTGRES_PASSWORD, BETTER_AUTH_SECRET (long random values, for example openssl rand -hex 32), BETTER_AUTH_URL (the exact origin users open), WEB_PORT (default 8080), JOB_MATCHER_VERSION (default latest) |
| Mode |
PROVIDERS (fake in the template) |
| API keys |
OPENROUTER_API_KEY, FIRECRAWL_API_KEY
|
| Tuning |
API_PORT, OPENROUTER_*, FIRECRAWL_CLOUD_PROXY, FIRECRAWL_CREDIT_RESERVE
|
Apply changes with docker compose up -d. Keep API_PORT=4000: the api healthcheck and the web image's baked-in API_URL both use port 4000. The stack scrapes with the Firecrawl cloud; for local or hybrid mode, run a Firecrawl instance yourself and add FIRECRAWL_MODE and FIRECRAWL_LOCAL_URL (reachable from the worker container). See Configuration Reference.
- All services join the project's default network. Containers reach each other by service name (
postgres,redis,api). - Only
webpublishes a port: hostWEB_PORTto container port 3000. Compose publishes it on all host interfaces, so other machines on the network can reach it unless a firewall blocks the port. - PostgreSQL, Redis and the API are not reachable from the host. Use
docker compose execto reach them (for exampledocker compose exec postgres psql -U postgres job_matcher). - The browser talks only to
web. Next.js rewrites/api/*tohttp://api:4000/api/*, so the session cookie is first-party and no CORS configuration is needed. - Outbound traffic comes from the
workerin live mode (Firecrawl, OpenRouter, the ATS APIs, resume downloads, Discord) and from theapiwhen it retries a failed match save and sends its Discord alert.
Sign-in works only from the origin in BETTER_AUTH_URL. Change it to the address people will open, then run docker compose up -d.
| Access | BETTER_AUTH_URL |
|---|---|
| This machine only |
http://localhost:8080 (installer default) |
| Your local network | http://<host-ip>:8080 |
| A domain with HTTPS | https://jobs.example.com |
For HTTPS, put a reverse proxy in front of WEB_PORT. With Caddy, which obtains certificates automatically, this Caddyfile is enough:
jobs.example.com {
reverse_proxy localhost:8080
}
Any reverse proxy works as long as it forwards to WEB_PORT and the public origin matches BETTER_AUTH_URL exactly, including scheme and port. Anyone who can reach the address can create an account, so do not expose an instance you do not want others to use. See Authentication.
| Task | Command (in the install folder) |
|---|---|
| Status | docker compose ps |
| Follow all logs | docker compose logs -f |
| Worker log | docker compose logs worker |
| Health |
curl http://localhost:8080/api/health (database and Redis) and /api/health/worker (a worker is connected) |
| Stop | docker compose stop |
| Start | docker compose up -d --wait |
Apply .env changes |
docker compose up -d |
The worker recovers on its own after a restart: on startup it marks runs left RUNNING without a queue job as failed (results saved so far are kept) and re-creates every user's schedule from the Settings table. See Run Pipeline.
All user data (accounts, settings, sources, runs, matches and the page cache) lives in PostgreSQL, in the job-matcher_postgres-data volume. Redis holds the queue and job schedulers, which the worker rebuilds from the database on startup.
Back up:
docker compose exec -T postgres pg_dump -U postgres --clean --if-exists job_matcher > job-matcher-backup.sqlRestore into a running install:
docker compose exec -T postgres psql -U postgres job_matcher < job-matcher-backup.sqlKeep .env with the backup. It holds POSTGRES_PASSWORD, which the existing volume was initialised with, and BETTER_AUTH_SECRET, which signs sessions.
| Install type | Steps |
|---|---|
| Installer | Run the installer again. It downloads the newest release's compose.yml and .env.example, sets JOB_MATCHER_VERSION in .env, keeps everything else, and runs docker compose up -d --pull missing --remove-orphans --wait. |
| By hand | Set JOB_MATCHER_VERSION in .env to the new release, download that release's deploy/compose.yml, and run docker compose up -d --wait. |
| From source |
git pull, then docker compose -f deploy/compose.yml -f deploy/compose.build.yml up -d --build --wait. |
Migrations run automatically through migrate. The installer does not add new variables from .env.example to an existing .env; compare the two after an upgrade. With JOB_MATCHER_VERSION=latest, --pull missing does not fetch a newer latest if one is already present locally; run docker compose pull first, or pin a version.
cd ~/job-matcher
docker compose down -v # stops the stack and deletes its volumes (all data)
cd .. && rm -rf job-matcherIf the folder is deleted without down -v, the volumes remain, and a later fresh install refuses to start ("A database from an earlier Job Matcher install exists"). Remove them with:
docker volume rm job-matcher_postgres-data job-matcher_redis-data| Resource | Requirement |
|---|---|
| Memory | About 2 GB free (self-hosting guide) |
| Disk | About 3 GB for images and data |
| Architecture |
linux/amd64 or linux/arm64 (Windows, macOS Intel and Apple Silicon, Linux, Raspberry Pi 4 or 5) |
| Network | Outbound HTTPS in live mode; GHCR access to pull images |
A self-hosted Firecrawl is not part of this stack. The development compose file's Firecrawl services need about 6 GB of images and about 3.5 GB of memory under light load (capped at 8 GB), on top of the above.
The worker processes one run at a time (BullMQ concurrency 1) and every run shares one Firecrawl rate limiter, so the stack is sized for personal use. See Architecture.
Job Matcher · Website · Report an issue · MIT license
Using Job Matcher
System design
Matching engine
Interface and brand
Engineering