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.

request flow
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.18 and npm. Check with node --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#

  1. Create a service user and unpack the kit
    Terminal
    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/current

    The current symlink makes upgrades a matter of pointing it at a new folder.

  2. Install production dependencies
    Terminal
    cd /opt/ats/current
    sudo npm ci --omit=dev

    Dev dependencies (Vite, test tools) are only needed to rebuild the UI, which the kit already contains in packages/ui/dist.

  3. 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_SECRET for your website.

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

    HOST=127.0.0.1 keeps 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.

  4. Try it once in the foreground
    Terminal
    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.

  5. Install the systemd unit
    /etc/systemd/system/ats.service
    [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.target
    Terminal
    sudo 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 node path if yours isn't /usr/bin/node (command -v node). StateDirectory=ats creates /var/lib/ats for the service user and keeps it writable under ProtectSystem=strict.

  6. Put the reverse proxy in front

    See the requirements and examples below.

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

  1. authenticate the user and set X-Forwarded-Email (or your PROXY_EMAIL_HEADER), overwriting anything the client sent;
  2. add X-ATS-Proxy-Secret with the value of PROXY_SECRET (header name configurable with PROXY_SECRET_HEADER);
  3. not require a login for /ats/ingest (your website calls it server to server, HMAC-signed);
  4. be the only thing that can reach the ATS port. Never publish it.

Caddy + Authelia#

Caddyfile
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:

server block (excerpt)
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_ENV other than development).
  • Health: GET /ats/api/health answers 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.

Terminal
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 files

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