Bare Node.js
One process: SQLite through node:sqlite, uploads on local disk, SMTP or Microsoft Graph for mail, behind your own authenticating reverse proxy. Node.js runs the TypeScript sources directly, so there is no server build step, and the kit ships the UI prebuilt.
browser ──▶ auth proxy (oauth2-proxy / Authelia / Authentik / Caddy forward_auth / ...)
│ adds X-Forwarded-Email + X-ATS-Proxy-Secret
▼
node apps/node/src/main.ts ─▶ DATA_DIR/ats.db, DATA_DIR/files/
/ats/ web app (packages/ui/dist)
/ats/api/* JSON API
/ats/ingest HMAC-signed intake from your website (bypass the login for this path)What you need#
- Node.js
>=22.18and npm. Check withnode --version. (The Docker image uses Node.js 26.) - A Linux machine with systemd (the examples use it; any supervisor works).
- An authenticating reverse proxy on the same machine or network.
Install#
- Create a service user and unpack the kit
sudo useradd --system --home-dir /var/lib/ats --shell /usr/sbin/nologin ats sudo mkdir -p /opt/ats sudo tar -xzf ats-0.1.0.tar.gz -C /opt/ats sudo ln -sfn /opt/ats/ats-0.1.0 /opt/ats/currentThe
currentsymlink makes upgrades a matter of pointing it at a new folder. - Install production dependencies
cd /opt/ats/current sudo npm ci --omit=devDev dependencies (Vite, test tools) are only needed to rebuild the UI, which the kit already contains in
packages/ui/dist. - Write the environment file
All settings are environment variables. Keep them outside the app folder so upgrades don't touch them. The heredoc fills in two fresh secrets; keep a copy of
INGEST_SECRETfor your website.sudo install -d -m 750 -o root -g ats /etc/ats sudo tee /etc/ats/ats.env >/dev/null <<EOF NODE_ENV=production PUBLIC_URL=https://ats.example.com BASE_PATH=/ats HOST=127.0.0.1 PORT=3000 DATA_DIR=/var/lib/ats INGEST_SECRET=$(openssl rand -hex 32) PROXY_SECRET=$(openssl rand -hex 32) BOOTSTRAP_ADMINS=you@example.com TRUST_PROXY=true EOF sudo chmod 640 /etc/ats/ats.env && sudo chown root:ats /etc/ats/ats.envHOST=127.0.0.1keeps the port off the network when the proxy runs on the same machine. Add mail settings if you want email; every variable is in Configuration. Put comments on their own lines, not after values. - Try it once in the foreground
sudo install -d -m 750 -o ats -g ats /var/lib/ats cd /opt/ats/current sudo -u ats node --env-file=/etc/ats/ats.env apps/node/src/main.ts # in another terminal: curl -s http://127.0.0.1:3000/ats/api/health # {"ok":true}If configuration is invalid, every problem is listed by variable name and the process exits before opening a port. Stop it with Ctrl+C.
- Install the systemd unit
[Unit] Description=ATS applicant tracking After=network-online.target Wants=network-online.target [Service] Type=simple User=ats Group=ats WorkingDirectory=/opt/ats/current ExecStart=/usr/bin/node --env-file=/etc/ats/ats.env apps/node/src/main.ts Restart=on-failure RestartSec=5 # SIGTERM: stop accepting requests, finish an in-flight scheduled run, close the database TimeoutStopSec=20 StateDirectory=ats StateDirectoryMode=0750 NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes [Install] WantedBy=multi-user.targetsudo systemctl daemon-reload sudo systemctl enable --now ats journalctl -u ats -f # one JSON object per line when NODE_ENV is not "development"Adjust the
nodepath if yours isn't/usr/bin/node(command -v node).StateDirectory=atscreates/var/lib/atsfor the service user and keeps it writable underProtectSystem=strict. - Put the reverse proxy in front
See the requirements and examples below.
- Sign in
Open
https://ats.example.com/ats/as a bootstrap admin and add your team under Settings → Users & access. Then connect your form.
Reverse proxy#
ATS trusts the email header only when the shared secret header also matches. Your proxy must:
- authenticate the user and set
X-Forwarded-Email(or yourPROXY_EMAIL_HEADER), overwriting anything the client sent; - add
X-ATS-Proxy-Secretwith the value ofPROXY_SECRET(header name configurable withPROXY_SECRET_HEADER); - not require a login for
/ats/ingest(your website calls it server to server, HMAC-signed); - be the only thing that can reach the ATS port. Never publish it.
Caddy + Authelia#
ats.example.com {
handle /ats/ingest {
reverse_proxy 127.0.0.1:3000
}
handle /ats* {
forward_auth 127.0.0.1:9091 {
uri /api/authz/forward-auth
copy_headers Remote-Email>X-Forwarded-Email Remote-Name
}
reverse_proxy 127.0.0.1:3000 {
header_up X-ATS-Proxy-Secret {env.ATS_PROXY_SECRET}
}
}
}Give Caddy the same secret as ATS_PROXY_SECRET in its environment.
nginx + oauth2-proxy (sketch)#
Run oauth2-proxy with --set-xauthrequest so its auth endpoint returns the user's email, then let nginx copy it and add the secret. Adapt to your setup:
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location = /oauth2/auth {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}
# signed intake from your website: no login, big enough for uploads
location = /ats/ingest {
client_max_body_size 41m;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:3000;
}
location /ats/ {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in;
auth_request_set $email $upstream_http_x_auth_request_email;
proxy_set_header X-Forwarded-Email $email;
proxy_set_header X-ATS-Proxy-Secret "paste PROXY_SECRET here";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:3000;
}Upload size. nginx defaults to a 1 MB request body. ATS accepts up to MAX_FILE_BYTES per file (10 MiB by default) and refuses an ingest request larger than four times that plus 1 MiB, so allow at least that much on /ats/ingest.
Operations#
- Scheduler: retention, audit pruning and other jobs run hourly inside the process and never overlap.
- Migrations run automatically at startup and are idempotent.
- Logs: one JSON object per line in production (
NODE_ENVother thandevelopment). - Health:
GET /ats/api/healthanswers without authentication.
Backups#
Back up DATA_DIR: snapshot the database with SQLite's online backup (safe while running; needs the sqlite3 command-line tool) and copy files/. Don't copy ats.db on its own: in WAL mode recent writes live in ats.db-wal.
sudo -u ats sqlite3 /var/lib/ats/ats.db ".backup /var/lib/ats/ats.bak"
sudo tar -czf ats-backup-$(date +%F).tar.gz -C /var/lib/ats ats.bak filesTo restore: stop the service, put the snapshot back as ats.db (delete any ats.db-wal and ats.db-shm), restore files/, make sure the ats user owns everything, and start it again.
Upgrading#
Unpack the new kit next to the old one, install its dependencies, move the current symlink and restart. Your environment file and DATA_DIR stay where they are. Step by step: Upgrading.