Public intake, private app
Only one thing ever needs to reach Gooseplicant from the internet: your website's signed applications. With INGEST_PORT, Gooseplicant opens a second port that accepts those and nothing else, so you can publish that port alone and keep the HR app, the API and every resume on your own network.
Self-hosted only. This is for the Docker and Node.js installs. On Cloudflare Workers the data lives in D1 and R2 in your Cloudflare account, so it can't stay on your premises.
Why#
Gooseplicant holds personal data: resumes, contact details and sometimes sensitive answers. Many teams want all of it on a machine they control, reachable only by staff, while the careers website (often hosted somewhere else entirely) still has to deliver applications around the clock.
The usual setup publishes the whole app behind a login and exempts /ats/ingest. That works, but the HR app, the API and the sign-in page are on the internet. Split the two instead: the website talks to a port that can only create applications, and everything people sign in to stays inside.
How it works#
Three settings do it:
| Variable | Default | What it does |
|---|---|---|
INGEST_PORT | off | Starts a second listener that serves only POST /ats/ingest (the same handler, signature check and limits as the main port) and GET /ats/api/health (for a tunnel or load-balancer health check). Nothing else is mounted on it: the HR app, Gosling, the API, files and import all answer a plain 404 Not found, whatever headers a caller sends. Must differ from PORT. |
INGEST_HOST | HOST | The interface the intake-only listener binds to. |
MAIN_INGEST | true | false: the main port answers 404 for /ats/ingest, so the port you published is the only way in. Requires INGEST_PORT. |
If you use a different BASE_PATH, replace /ats accordingly. All other variables are unchanged; see Configuration.
Configure Gooseplicant#
Node.js#
Add three lines to your environment file (for example /etc/ats/ats.env) and restart the service:
INGEST_PORT=3001
MAIN_INGEST=false
# The address staff use on the inside: email links to HR point here, and it is the CSRF origin.
PUBLIC_URL=https://ats.corp.example.comWith HOST=127.0.0.1, the intake port also binds to loopback, which is what you want when cloudflared runs on the same machine. Set INGEST_HOST if the tunnel or port forward reaches it over the network instead.
Docker Compose#
The kit's apps/node/docker-compose.example.yml has this layout commented out at the end. In the ats service, uncomment INGEST_PORT, MAIN_INGEST and expose "3001", and set PUBLIC_URL to the address staff use on the inside:
services:
ats:
image: gooseplicant:latest
restart: unless-stopped
environment:
PUBLIC_URL: https://ats.corp.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"
INGEST_PORT: "3001"
MAIN_INGEST: "false"
volumes:
- ats-data:/data
expose:
- "3000"
- "3001"
networks: [internal]The image already declares both ports (EXPOSE 3000 3001); 3001 is only used when INGEST_PORT=3001 is set.
Cloudflare Tunnel#
A tunnel needs no inbound ports and no public IP: cloudflared dials out to Cloudflare and forwards one hostname, say apply.example.com, to the intake port. Cloudflare terminates HTTPS for that hostname.
With Docker Compose#
- Create the tunnel
In Cloudflare Zero Trust, go to Networks → Tunnels, create a tunnel, and put its token in
.envnext todocker-compose.ymlasTUNNEL_TOKEN. - Add the public hostname
In the tunnel, add the public hostname
apply.example.comwith the servicehttp://ats:3001. - Add the
cloudflaredserviceUncomment it in the example file. It publishes no ports; nothing on your router needs to be opened.
cloudflared: image: cloudflare/cloudflared:2026.9.3 restart: unless-stopped command: tunnel --no-autoupdate run environment: TUNNEL_TOKEN: ${TUNNEL_TOKEN:?set in .env} networks: [internal]docker compose up -d
With cloudflared on the host#
For a Node.js install, or a container published on loopback (-p 127.0.0.1:3001:3001), run cloudflared on the same machine and manage the tunnel from the command line:
cloudflared tunnel login
cloudflared tunnel create gooseplicant-intake
cloudflared tunnel route dns gooseplicant-intake apply.example.com
cloudflared tunnel run gooseplicant-intakeIts configuration goes in ~/.cloudflared/config.yml (or /etc/cloudflared/config.yml when it runs as a service):
tunnel: gooseplicant-intake
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: apply.example.com
service: http://localhost:3001
- service: http_status:404The last rule answers anything else that reaches the tunnel with a 404.
Without Cloudflare: one forwarded port#
Forward a single router or firewall port to INGEST_PORT, ideally through a TLS-terminating reverse proxy (Caddy, nginx) that also limits the path to /ats/ingest. Never forward PORT: that is the app.
apply.example.com {
handle /ats/ingest {
reverse_proxy 127.0.0.1:3001
}
handle {
respond 404
}
}Allow request bodies of at least four times MAX_FILE_BYTES plus 1 MiB if your proxy limits them (nginx: client_max_body_size 41m with the defaults).
Staff access#
- On site: the authenticating proxy stays as it was (see Docker or Node.js), but publish it on a LAN address only, for example
ports: ["192.168.1.10:443:443"]in Compose. - From home: put the proxy, or the whole host, on Tailscale or your VPN instead of opening it to the internet.
- Drop the ingest exemption: with
MAIN_INGEST=falsethe main port answers 404 for/ats/ingest, so the proxy'shandle /ats/ingestblock (or nginxlocation = /ats/ingest) can go. - Keep
PUBLIC_URLat the internal address, such ashttps://ats.corp.example.com. Links in HR emails then work for staff and lead nowhere for anyone else, and it stays the origin the CSRF check expects. Use a certificate your staff's browsers trust, for example one from a DNS challenge or your internal CA.
Point your website at it#
Your website posts to ${ATS_URL}/ingest, so set its ATS_URL to the public intake hostname. The secret and the signing code stay exactly as in Connect a form.
ATS_URL=https://apply.example.com/ats
ATS_INGEST_SECRET=the same value as INGEST_SECRETFor the tunnel's or your monitoring's health check, use https://apply.example.com/ats/api/health.
Check it#
# from anywhere: the intake side is alive
curl -s https://apply.example.com/ats/api/health # {"ok":true}
# from anywhere: nothing else is there
curl -s -o /dev/null -w '%{http_code}\n' https://apply.example.com/ats/ # 404
curl -s -o /dev/null -w '%{http_code}\n' https://apply.example.com/ats/api/me # 404
# on the server (Node.js install): the main port no longer accepts applications
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:3000/ats/ingest # 404Then send a real test submission to https://apply.example.com/ats/ingest with the curl recipe and find it in the inbox.
What the public side can and can't do#
Someone who finds apply.example.com can:
- Create an application, and only with a valid HMAC-SHA256 signature made with your
INGEST_SECRET, timestamped within 5 minutes of the server's clock. Headers are checked before the body is read, so unsigned callers can't make it buffer uploads. - Only within the size limits: each file up to
MAX_FILE_BYTES(10 MiB by default), the whole request up to four times that plus 1 MiB, at most 20 files, each bound to the signed payload by SHA-256 and size. - Not twice: a repeat with the same
externalIdreturns the original application, and an identical signed request without one is refused with409. Both listeners share one replay guard. - Ask whether it is up, through
/ats/api/health.
They can't read, list, search, download or change anything. There is no sign-in page, no API, no files and no web app on that port, and a submission never overwrites an existing candidate's details. See the security model.
Limits#
- Self-hosted only. The Cloudflare Workers runtime stores data in D1 and R2, so it can't keep the data on your premises. Use the Docker or Node.js install when that is the requirement.
- One host. Both ports belong to the same process and share its database and files folder; there is nothing to sync.
- The website must be able to reach the intake hostname over HTTPS, and its clock must be right to within a few minutes.
- Imports of old emails and PDFs happen in the app, on the inside; they never use the public port.