Skip to content

Deployment

Karthik P edited this page Oct 15, 2026 · 1 revision

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.

Topology

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"]
Loading

Dockerfile

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.

Stage 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

Target api

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.

Target web

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

What the images contain

  • Both images contain the full workspace node_modules from base, including dev dependencies and the other app's dependencies, plus prisma/, packages/shared and the generated Prisma client. The api image has no Next.js build; the web image has no API source.
  • Neither image defines a Docker HEALTHCHECK. Health checks are defined in deploy/compose.yml (below).
  • Both run as the non-root bun user.

.dockerignore

.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

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_matcher and REDIS_URL=redis://redis:6379, overriding anything in .env
  • restart: unless-stopped
  • depends_on: migrate completed successfully and redis healthy

Services

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.

Migrations on startup

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

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 --wait

Built images keep the image: names from compose.yml, so they are tagged ghcr.io/karthik-beta/job-matcher-*:<JOB_MATCHER_VERSION> locally.

Configuration

.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.

Networking

  • All services join the project's default network. Containers reach each other by service name (postgres, redis, api).
  • Only web publishes a port: host WEB_PORT to 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 exec to reach them (for example docker compose exec postgres psql -U postgres job_matcher).
  • The browser talks only to web. Next.js rewrites /api/* to http://api:4000/api/*, so the session cookie is first-party and no CORS configuration is needed.
  • Outbound traffic comes from the worker in live mode (Firecrawl, OpenRouter, the ATS APIs, resume downloads, Discord) and from the api when it retries a failed match save and sends its Discord alert.

Opening it to other devices, and HTTPS

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.

Operations

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.

Backup and restore

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.sql

Restore into a running install:

docker compose exec -T postgres psql -U postgres job_matcher < job-matcher-backup.sql

Keep .env with the backup. It holds POSTGRES_PASSWORD, which the existing volume was initialised with, and BETTER_AUTH_SECRET, which signs sessions.

Upgrade

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.

Uninstall

cd ~/job-matcher
docker compose down -v    # stops the stack and deletes its volumes (all data)
cd .. && rm -rf job-matcher

If 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 requirements

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.

Clone this wiki locally