Self-host an ATS with Docker Compose in 15 minutes
Self-host an ATS with Docker: the exact Vekt commands, .env settings, HTTPS with Caddy, volume backups and a go-live checklist.
To self-host an ATS with Docker, you need a Linux server, a domain name and Docker Compose. This tutorial walks through the whole setup for Vekt, our open-source recruiting platform: the install commands, the .env settings that matter, HTTPS behind a reverse proxy, backups of the two data volumes, and a checklist to run before the first real candidate applies. The commands themselves take a few minutes; DNS and email-domain verification can take longer, so start those first.
Every Vekt command and setting below comes from the Vekt repository (its README, .env.example, docker-compose.yml, setup.sh and entrypoint script) as of 3 November 2026. Facts about Docker, Caddy and SQLite link to their own documentation.
What you will run
Vekt’s docker-compose.yml starts two containers and two named volumes:
internet
│ :443 (HTTPS)
┌──────▼──────┐
│ Caddy │ on the host, gets the certificate
└──────┬──────┘
│ 127.0.0.1:3000
┌────────────────▼────────────────┐ ┌───────────────────────┐
│ app (ghcr.io/behnoudmst/vekt) │◄─────►│ inngest │
│ Next.js, Prisma, SQLite │ │ background jobs, │
│ │ │ 127.0.0.1:8288 │
└───────┬─────────────────┬───────┘ └───────────────────────┘
│ │
volume db-data volume uploads
/data/app.db /app/private/uploads (CV PDFs)
appis the Next.js application on port 3000. On every start, its entrypoint checks the required secrets, runs the database migrations and then starts the server.inngestis a self-hosted Inngest server that runs the three background jobs: CV evaluation, delayed status emails, and the daily purge of expired candidate data at 02:00 UTC. Compose binds its port to127.0.0.1only.db-dataholds the SQLite database;uploadsholds the CV files, which are served only to logged-in users through a protected API route.
That is the entire stack. There is no separate database server to run, which is why a small VPS is enough to start.
Before you start
- A Linux server with Docker and the Compose v2 plugin (
docker compose versionshould work), pluscurlandopenssl. - A DNS record (for example
jobs.example.com) pointing at the server, and ports 80 and 443 open. Caddy needs both to obtain a certificate (Caddy docs). - Optional: a Resend account with a verified sending domain, if you want Vekt to email candidates. All candidate emails are sent through Resend.
- Optional: an AI provider. The default is
mock, which makes no external calls, so you can install and test without choosing one.
Step 1: Install Vekt
Create a directory and run the setup script from the README:
mkdir -p /opt/vekt && cd /opt/vekt
curl -fsSL https://raw.githubusercontent.com/Behnoudmst/vekt/main/setup.sh | bash
The script downloads docker-compose.yml and .env.example (saved as .env), generates AUTH_SECRET, INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY with openssl, asks for an admin email and password, a company name, a public URL, an AI provider and an optional Resend key, then pulls the images and starts both containers.
If you would rather not pipe a script to bash, the README’s manual route is:
curl -O https://raw.githubusercontent.com/Behnoudmst/vekt/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/Behnoudmst/vekt/main/.env.example
mv .env.example .env
# Edit .env: set AUTH_SECRET, INNGEST_EVENT_KEY, INNGEST_SIGNING_KEY,
# SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD (and SEED_ON_START=true for the first boot)
docker compose up -d
.env.example gives the generators: openssl rand -base64 32 for AUTH_SECRET, and openssl rand -hex 32 for INNGEST_SIGNING_KEY, which must be an even-length hex string. The app container refuses to start without AUTH_SECRET and both Inngest keys.
Step 2: The .env settings that matter
Here is a production .env for jobs.example.com. Values in angle brackets are yours to fill in.
# Public URL (used for login and for links in candidate emails)
NEXTAUTH_URL="https://jobs.example.com"
APP_URL="https://jobs.example.com"
# Secrets (setup.sh generates these)
AUTH_SECRET="<openssl rand -base64 32>"
INNGEST_EVENT_KEY="<openssl rand -hex 16>"
INNGEST_SIGNING_KEY="<openssl rand -hex 32>"
# First boot only: creates the admin account, then set back to false
SEED_ON_START="true"
SEED_ADMIN_EMAIL="<you@example.com>"
SEED_ADMIN_PASSWORD="<a long random password>"
# AI scoring: mock | openai | openrouter | ollama
AI_PROVIDER="mock"
# Candidate email via Resend (leave the key blank to send nothing)
RESEND_API_KEY="<re_...>"
EMAIL_FROM="jobs@example.com"
COMPANY_NAME="Example Ltd"
PRIVACY_CONTACT_EMAIL="privacy@example.com"
LOG_LEVEL="info"
Two details are easy to miss.
Compose only passes the variables it lists. The shipped docker-compose.yml reads .env to fill in the variables named in the app service’s environment block, and nothing else reaches the container. NEXTAUTH_URL, the secrets, the seed settings, the AI settings, COMPANY_NAME and LOG_LEVEL are listed. APP_URL, RESEND_API_KEY, EMAIL_FROM and OPENAI_MODEL are not, so on a stock install no candidate email is sent and status links would point at http://localhost:3000. The override file in step 3 passes them through.
SEED_ON_START runs on every start. When it is true, the entrypoint runs the seed script each time the container starts. The script sets the admin password back to SEED_ADMIN_PASSWORD and recreates the sample “UX/UI Designer” job if you deleted it. setup.sh sets it to true, so after your first successful login set SEED_ON_START="false", remove the password from .env, and run docker compose up -d again.
Step 3: An override file for production
Rather than editing the downloaded docker-compose.yml (re-running setup.sh downloads it again), put your changes in a second file. Save this as docker-compose.override.yml next to it:
services:
app:
# Only the reverse proxy on this host should reach the app
ports: !override
- "127.0.0.1:3000:3000"
environment:
APP_URL: "${APP_URL}"
RESEND_API_KEY: "${RESEND_API_KEY:-}"
EMAIL_FROM: "${EMAIL_FROM:-}"
OPENAI_MODEL: "${OPENAI_MODEL:-gpt-4o}"
Then start the stack with both files:
docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
Environment variables in the second file are added to the first. The ports line needs the !override tag: without it Compose keeps both mappings, so port 3000 would stay open on every interface. !override requires Docker Compose 2.24.4 or later (Compose merge rules).
Binding to 127.0.0.1 matters more than it looks. Docker’s documentation says that publishing container ports “is insecure by default” when no host IP is given (port publishing), and that Docker and ufw are incompatible, because published-port traffic is routed before ufw’s rules apply (packet filtering and firewalls). A ufw rule blocking 3000 would not protect you; the localhost binding does.
Step 4: HTTPS with Caddy
Any reverse proxy works. Caddy is the shortest, because it obtains and renews the certificate for you once DNS points at the server. Install it from your distribution’s packages and put this in /etc/caddy/Caddyfile:
jobs.example.com {
reverse_proxy 127.0.0.1:3000
}
Reload Caddy (sudo systemctl reload caddy) and open https://jobs.example.com. The Compose file already sets AUTH_TRUST_HOST=true, so login works behind the proxy; NEXTAUTH_URL must be the public https:// address. If you use nginx or Traefik instead, proxy to the same 127.0.0.1:3000.
Step 5: Back up db-data and uploads
The two volumes are your whole recruiting history: the database and the CV files. Because Compose names the project vekt, they appear as vekt_db-data and vekt_uploads in docker volume ls, and the app container is called vekt-app.
The database is a single SQLite file, and SQLite warns that copying a database file while a write is in progress can produce a corrupt backup (How To Corrupt An SQLite Database File, section 1.2). The simplest safe method is to stop the app for the few seconds the copy takes. This script follows the pattern in Docker’s volume backup guide, using --volumes-from to mount both volumes:
#!/usr/bin/env bash
# /opt/vekt/backup.sh: back up the Vekt database and CV files
set -euo pipefail
cd /opt/vekt
mkdir -p backups
docker compose stop app
docker run --rm --volumes-from vekt-app -v "$PWD/backups:/backup" alpine \
tar czf "/backup/vekt-$(date +%F).tar.gz" /data /app/private/uploads
docker compose start app
# keep the last 14 archives
ls -1t backups/vekt-*.tar.gz | tail -n +15 | xargs -r rm --
Run it nightly from root’s crontab, away from the 02:00 UTC purge:
30 3 * * * /opt/vekt/backup.sh >> /var/log/vekt-backup.log 2>&1
To restore, stop the app, unpack an archive into the same paths and start it again:
docker compose stop app
docker run --rm --volumes-from vekt-app -v "$PWD/backups:/backup" alpine \
sh -c "cd / && tar xzf /backup/vekt-2026-11-03.tar.gz"
docker compose start app
Two things to keep in mind. The archives contain CVs and personal data, so copy them off the server to storage you control, encrypted, and give them a retention period no longer than you need. And the in-app retention setting only deletes records from the live database: old backups keep deleted candidates until the archives themselves expire, so set the backup rotation to match your retention policy.
Step 6: Updates
The image is published to ghcr.io/behnoudmst/vekt on each GitHub release, and latest points at the newest stable one. To update, take a backup, then pull and restart:
./backup.sh
docker compose -f docker-compose.yml -f docker-compose.override.yml pull
docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
Database migrations run automatically when the new container starts. If you prefer to choose when you upgrade, set image: in the override file to a version tag instead of latest; the release workflow tags each image with its version number, and the releases page lists them.
Go-live checklist
Run through this before you publish a real job:
| Check | How |
|---|---|
| HTTPS works and port 3000 is closed | https://jobs.example.com loads; from another machine, curl http://<server-ip>:3000 fails |
| Seeding is off | SEED_ON_START="false" and no SEED_ADMIN_PASSWORD in .env, then docker compose up -d |
| Admin password is your own | Log in at /login and confirm the password you chose works |
| Recruiters have their own accounts | Create them in the Admin Dashboard; don’t share the admin login |
| Sample job is gone | Delete “UX/UI Designer” from the recruiter dashboard |
| Retention period is set | Admin Dashboard: data retention (90 days if never set) |
| Emails are delivered | RESEND_API_KEY and EMAIL_FROM reach the container (override file), sending domain verified in Resend; apply to a test job with your own address |
| Status links point at your domain | The link in the test email starts with https://jobs.example.com |
| AI provider is the one you mean | AI_PROVIDER set; with mock, scores are random placeholders |
| Privacy notice is yours | Read /privacy as a candidate would: controller name, contact address, retention period |
| Backups run and restore | Run backup.sh, restore into a test install once |
| Logs are readable | docker compose logs -f app shows the evaluation of your test application |
A note on the AI provider and where data goes
With AI_PROVIDER=mock or ollama, CV scoring happens on hardware you run, so candidate data stays on your servers. With openai or openrouter, the CV text is sent to that provider after Vekt removes names, contact details and other direct identifiers. Candidate emails, whatever the provider, go through Resend.
The score itself is a reading-order suggestion: it is uncalibrated, and below the threshold candidates are marked needs review rather than rejected, unless an admin turns on automatic rejection. Self-hosting gives you control over where the data lives; the privacy notice, the lawful basis and answers to candidate requests remain your organisation’s job. More on the platform is on the Vekt home page, and the code and licence are on our open-source page.
FAQ
What server do I need to self-host an ATS with Docker?
For Vekt, any Linux server that runs Docker with Compose v2. The stack is two containers and a SQLite file, with no separate database server. If you run a local model with Ollama on the same machine, size the server for the model, not for Vekt.
Do I need a domain name and HTTPS?
For anything beyond a test, yes. Candidates upload CVs and recruiters log in, so the site should only be reachable over HTTPS. A reverse proxy such as Caddy handles the certificate, and Vekt’s port should stay bound to localhost.
Why are no candidate emails sent after setup?
On a stock install, RESEND_API_KEY, EMAIL_FROM and APP_URL are in .env but not in the Compose file’s list of variables passed to the container. Add them through an override file as shown above, and verify your sending domain in Resend.
How do I back up a self-hosted ATS?
Back up both volumes, db-data (the SQLite database) and uploads (the CV files), with the app stopped for the few seconds the copy takes, and keep the archives encrypted and off the server. Test a restore at least once.
Is Vekt free to self-host?
Yes. Vekt is MIT-licensed, with no seat or job limits. Your costs are the server, your time to run it, and any AI provider or email service you choose to use.