Docker + Compose
The recommended self-hosted setup: one Gooseplicant container that only your authenticating reverse proxy can reach, with its database and uploads on a Docker volume.
How sign-in works here. Gooseplicant never sees passwords. Your proxy signs the user in and forwards their email in a header. Gooseplicant trusts that header only when a second header carries the shared PROXY_SECRET, so someone who reaches the container directly can't impersonate a user.
What you need#
- Docker with BuildKit and Docker Compose v2. The image builds from the kit, on top of
node:26-slim. - A hostname for Gooseplicant (for example
ats.example.com) pointing at the machine, ports 80 and 443 open for the proxy. - An identity provider your proxy can use. The example uses Authelia with Caddy; oauth2-proxy and others work too.
Install#
- Download, verify and unpack the kit
curl -LO https://gooseplicant.goosenotes.dev/downloads/0.3.0/gooseplicant-0.3.0.tar.gz curl -LO https://gooseplicant.goosenotes.dev/downloads/0.3.0/SHA256SUMS grep ' gooseplicant-0.3.0.tar.gz$' SHA256SUMS | shasum -a 256 -c tar -xzf gooseplicant-0.3.0.tar.gz cd gooseplicant-0.3.0 - Build the image
From the root of the unpacked kit (the image needs the whole workspace). The UI is compiled inside the build; there is no server build step because Node.js runs the TypeScript sources directly.
docker build -f apps/node/Dockerfile -t gooseplicant:0.3.0 -t gooseplicant:latest . - Create a deployment folder with the Compose file
mkdir -p ~/ats-deploy cp apps/node/docker-compose.example.yml ~/ats-deploy/docker-compose.yml cd ~/ats-deploy - Generate the two secrets
Compose reads them from
.envnext todocker-compose.yml. Keep a copy ofATS_INGEST_SECRET: your website needs the same value.printf 'ATS_INGEST_SECRET=%s\nATS_PROXY_SECRET=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env chmod 600 .env - Edit the
atsserviceIn
docker-compose.ymlcheck thatimageis the tag you just built (gooseplicant:latest), setPUBLIC_URLto your public origin andBOOTSTRAP_ADMINSto your email. UncommentMAIL_FROMandSMTP_URLif you want email, and addEDITION: liteif you only want Gosling. The rest of the example already fits this setup:services: ats: image: gooseplicant:latest restart: unless-stopped environment: PUBLIC_URL: https://ats.example.com BASE_PATH: /ats INGEST_SECRET: ${ATS_INGEST_SECRET:?set in .env} PROXY_SECRET: ${ATS_PROXY_SECRET:?set in .env} PROXY_EMAIL_HEADER: x-forwarded-email BOOTSTRAP_ADMINS: you@example.com TRUST_PROXY: "true" # EDITION: lite # Gosling only; default is full # MAIL_FROM: careers@example.com # SMTP_URL: smtps://user:password@smtp.example.com:465 volumes: - ats-data:/data expose: - "3000" networks: [internal]Note
expose, notports: the container is reachable on the internal network only. All variables are listed in Configuration.The image declares two ports,
EXPOSE 3000 3001. 3000 is the app; 3001 is used only when you setINGEST_PORT=3001for an intake-only listener, which lets you publish applications from your website without publishing the app. See Public intake, private app. - Write the Caddyfile
Create
Caddyfilenext todocker-compose.yml. Requests to/ats/ingestskip the login (they are HMAC-signed, server to server); everything else under/atsgoes through Authelia, which supplies the email, and Caddy adds the shared secret.ats.example.com { handle /ats/ingest { # server-to-server, HMAC-signed: no user login reverse_proxy ats:3000 } handle /ats* { forward_auth authelia:9091 { uri /api/authz/forward-auth copy_headers Remote-Email>X-Forwarded-Email Remote-Name } reverse_proxy ats:3000 { header_up X-ATS-Proxy-Secret {env.ATS_PROXY_SECRET} } } } - Add your Authelia service
The example Compose file references
authelia:9091but does not define it. Add anautheliaservice on the sameinternalnetwork, configured for your users or identity provider, following Authelia's own documentation. Any proxy that authenticates users and adds the two headers works instead; see other proxies. - Start it
docker compose up -d docker compose ps # the ats service turns "healthy" once it answers /ats/api/health docker compose logs -f ats # one JSON object per lineConfiguration is validated at startup: if anything is missing or invalid, every problem is listed by variable name and the container exits before opening a port.
- Sign in and invite your team
Open
https://ats.example.com/ats/, sign in as a bootstrap admin, then add people under Settings → Users & access. Gosling is at/ats/lite/. Next, connect your form.
Proxy requirements#
Whatever you put in front, it must:
- authenticate the user and set
X-Forwarded-Email(or yourPROXY_EMAIL_HEADER), overwriting anything the client sent; - add
X-ATS-Proxy-Secret: $PROXY_SECRETto the requests it forwards; - not require a login for
/ats/ingest; - be the only thing that can reach the Gooseplicant port. Never publish it.
Want the HR app reachable only inside your network? Publish the intake alone through a tunnel and keep the proxy on the LAN: Public intake, private app.
With TRUST_PROXY=true, Gooseplicant takes the client IP for the audit log from the right-most X-Forwarded-For entry, so the proxy must append to that header.
Other proxies#
- Authelia sends
Remote-Email. Either copy it toX-Forwarded-Emailas above, or setPROXY_EMAIL_HEADER=remote-email. - oauth2-proxy: use
--pass-user-headerswhen it proxies directly, or--set-xauthrequestwith nginxauth_request. InjectX-ATS-Proxy-Secretin the fronting proxy, and exempt/ats/ingestfrom authentication. An nginx sketch is in the Node.js guide. - Display names: set
PROXY_NAME_HEADER(for exampleremote-nameorx-forwarded-preferred-username) to show names instead of emails.
Without Compose#
If your proxy already runs on the host, a single container bound to loopback works too:
docker run -d --name gooseplicant --restart unless-stopped \
-v ats-data:/data --env-file ats.env \
-p 127.0.0.1:3000:3000 gooseplicant:latestKeep comments on their own lines in env files. docker run --env-file does not strip a trailing # comment; it becomes part of the value.
Volumes and data#
| Volume | Mounted at | Holds |
|---|---|---|
ats-data | /data | ats.db (SQLite in WAL mode, with its -wal/-shm files) and files/ (uploaded resumes and attachments). This is everything to back up. |
caddy-data | /data in the proxy | Caddy's certificates and state. |
The container runs as the non-root node user. On SIGTERM (docker compose stop) it stops accepting requests, waits for an in-flight scheduled run and closes the database.
Backups#
Snapshot the database with SQLite's online backup (safe while Gooseplicant runs) and archive the files folder. Don't copy ats.db alone: in WAL mode recent writes live in the -wal file. Compose prefixes volume names with the project (folder) name; check yours with docker volume ls.
mkdir -p backups
docker run --rm -v ats-deploy_ats-data:/data -v "$PWD/backups":/backup alpine:3 sh -c '
apk add --no-cache sqlite >/dev/null &&
sqlite3 /data/ats.db ".backup /backup/ats-$(date +%F).db" &&
tar -czf /backup/files-$(date +%F).tar.gz -C /data files'To restore, stop the service, put the snapshot back as ats.db (removing any ats.db-wal/ats.db-shm), unpack files/, and give everything back to the node user (uid 1000):
docker compose stop ats
docker run --rm -v ats-deploy_ats-data:/data -v "$PWD/backups":/backup alpine:3 sh -c '
rm -f /data/ats.db-wal /data/ats.db-shm &&
cp /backup/ats-2026-01-31.db /data/ats.db &&
rm -rf /data/files && tar -xzf /backup/files-2026-01-31.tar.gz -C /data &&
chown -R 1000:1000 /data'
docker compose start atsUpgrading#
Back up, unpack the new kit, rebuild the image and recreate the container; the volume is kept and database migrations run automatically on start.
cd gooseplicant-0.3.0 # the NEW kit
docker build -f apps/node/Dockerfile -t gooseplicant:0.3.0 -t gooseplicant:latest .
cd ~/ats-deploy && docker compose up -d # recreates ats with the new imageDetails and rollback advice: Upgrading.