Set up on one server

The recommended way to run Gooseplicant for your own company: one Linux server (or a Mac) with Docker, your domain on Cloudflare, and about ten minutes. The kit's goose script asks plain questions and sets up the rest. You don't need to know Caddy, Authelia or Cloudflare Tunnels to follow this guide.

Two kinds of boxes run through this guide:

You do this

Something only a person can do outside the terminal: click through a dashboard, copy a value, open a browser, change your website's settings.

goose does this

What happens by itself once you run the command. Nothing to do but read it if you're curious.

0. What you'll end up with#

Gooseplicant on one server Staff on the office network or Tailscale open hire.example.com. Caddy answers on one internal IP address with HTTPS, using a Let's Encrypt certificate it gets through Cloudflare DNS, then Authelia checks the sign-in against your own user list with optional two-step, then Gooseplicant port 3000 serves the app. From the internet, your careers website posts signed applications to apply.example.com at Cloudflare, which sends them through a Tunnel to cloudflared on your server, which reaches only Gooseplicant's intake-only port 3001. Nothing on the server is opened to the internet. INTERNET Careers website posts each application apply.example.com Cloudflare your domain's DNS, and the Tunnel to the intake (no port forwarding) YOUR SERVER · NOTHING OPEN TO THE INTERNET Tunnel cloudflared outbound only Let's Encrypt certificate via DNS Gooseplicant :3001 intake only signed applications :3000 the app HR app, Gosling, API and files Staff LAN or Tailscale hire.example.com Caddy HTTPS on one internal IP Authelia your own users 2-step optional
Gooseplicant on one server Your careers website posts to apply.example.com at Cloudflare, which sends applications through a Tunnel to cloudflared on your server and on to Gooseplicant's intake-only port 3001. Staff on the LAN or Tailscale reach Caddy, which serves HTTPS on one internal IP, then sign in at Authelia, then use Gooseplicant on port 3000. Careers website apply.example.com Cloudflare DNS, certificates, Tunnel Tunnel YOUR SERVER cloudflared outbound only Gooseplicant :3001 intake only :3000 the app HR app, Gosling, API, files Authelia your users, 2-step Caddy HTTPS on one internal IP Staff LAN or Tailscale · hire.example.com
  • Staff open https://hire.example.com/ats/ on the office network or over Tailscale. Caddy answers on that one internal address, with a real Let's Encrypt certificate it gets and renews through Cloudflare's DNS. No certbot, and no port opened on your router.
  • Everyone signs in at auth.example.com (Authelia) with an account you create. Two-step sign-in with an authenticator app or a passkey is on by default.
  • Inside, Gooseplicant decides what each person may do: the role and sites you give them under Settings → Users & access.
  • Your careers website posts applications to apply.example.com. Cloudflare passes them through a Tunnel to a port that accepts signed applications and nothing else. The HR app, the sign-in page and every resume stay off the internet.
The three hostnames
Hostname (default)Who uses itPoints at
hire.example.comStaff, in the browserYour server's internal IP (an A record, DNS only)
auth.example.comStaff, for signing inThe same internal IP
apply.example.comYour website's serverThe Cloudflare Tunnel (a proxied CNAME that goose, or the Tunnel dashboard, creates)

1. Before you start#

  • A server that stays on: a Linux machine with Docker Engine and the Docker Compose v2 plugin, plus openssl and curl. A Mac with Docker Desktop works too, which is handy for trying it; for day-to-day use a small Linux box is the better home.
  • Internet access from that server for the first start: it downloads the base images and builds Caddy with its Cloudflare plugin (Go modules from proxy.golang.org).
  • A domain whose DNS is on Cloudflare, for example example.com. The free plan is fine.
  • The server's internal IP address: its LAN address (like 192.168.1.20) or its Tailscale address (like 100.101.102.103). You don't need to look it up: goose lists the addresses it finds. Give the server a fixed LAN address (a DHCP reservation on your router) so the address doesn't change.
  • Ports 443 and 80 free on that address. If something else uses them, goose lets you pick other ports.
  • Your email address: it becomes the first Gooseplicant admin.
  • Optional: SMTP details (server, port, username, password) if sign-in codes and password resets should arrive by email. Without them you read codes on the server with ./goose code.
  • About ten minutes, plus a few for the first build.

Check the tools on the server:

Terminal
docker compose version     # Docker Compose version v2.x
openssl version
curl --version | head -n 1

On a Mac with Docker Desktop, start Docker Desktop before running goose, and if you use the Tailscale address, start Tailscale first: Docker can only listen on an address that exists when the container starts.

Already run Authelia? Bring your accounts along: ./goose setup --import-users /path/to/users_database.yml imports an existing Authelia users file and keeps its password hashes. People still register their two-step device again, and you still invite them inside Gooseplicant (step 9).

2. Create a Cloudflare API token#

Caddy uses this token to prove to Let's Encrypt that you own the domain (by adding a temporary DNS record), and goose can use it to create your DNS records and the Tunnel. It is only ever stored on your server.

You do this

  1. Open dash.cloudflare.com/profile/api-tokens and click Create Token.
  2. Next to Edit zone DNS click Use template (or Create Custom Token → Get started).
  3. Permissions: keep or add these rows:
    • Zone · DNS · Edit
    • Zone · Zone · Read
    • For the public intake tunnel (optional) also add: Account · Cloudflare Tunnel · Edit
  4. Zone Resources: Include · Specific zone · example.com (add every zone that holds a Gooseplicant hostname). Account Resources (only needed with the tunnel row): Include · your account.
  5. Optional: Client IP Address Filtering → this server's public IP address.
  6. Continue to summary → Create Token. Copy the token: Cloudflare shows it only once. Keep it in your password manager until step 4.
Why each permission
PermissionWhy
Zone · Zone · ReadFind your domain in Cloudflare (Caddy and goose).
Zone · DNS · EditThe temporary record Let's Encrypt checks for each certificate, and the internal A records if you let goose create them.
Account · Cloudflare Tunnel · Edit (optional)goose creates the Tunnel and its settings for you. Without it you click the Tunnel together in the dashboard (step 5).

Caddy keeps the token (in deploy/.env) to renew certificates, so leave the DNS rows in place. You can remove the tunnel permission again once setup is done. No token yet? Leave the question empty in step 4: goose prints these same steps and carries on, and you run ./goose setup again later.

3. Download and unpack the kit#

On the server, in a folder you'll keep, for example ~/gooseplicant:

Terminal
mkdir -p ~/gooseplicant && cd ~/gooseplicant
curl -LO https://gooseplicant.goosenotes.dev/downloads/0.7.0/gooseplicant-0.7.0.tar.gz
curl -LO https://gooseplicant.goosenotes.dev/downloads/0.7.0/SHA256SUMS
grep ' gooseplicant-0.7.0.tar.gz$' SHA256SUMS | shasum -a 256 -c     # prints "gooseplicant-0.7.0.tar.gz: OK"
tar -xzf gooseplicant-0.7.0.tar.gz
cd gooseplicant-0.7.0/deploy

On Linux without shasum, use sha256sum -c the same way. Keep the gooseplicant-0.7.0 folder: deploy/ is where goose keeps your settings, secrets and backups, and ./goose update later updates this same folder in place (step 11). On a Mac, keep it out of Desktop, Documents and Downloads: cron can't read those, so scheduled updates would fail.

4. Run ./goose setup#

Terminal
./goose setup

It asks one question at a time. A value in [brackets] is the default: press Enter to take it. Passwords and tokens don't show while you type or paste them. You can run setup again at any time: answers then default to your current settings, and secrets and users are kept.

Hostnames#

Your domain (managed in Cloudflare), e.g. example.com
Your domain as it appears in Cloudflare, like example.com.
Hostname staff use for Gooseplicant [hire.example.com]
What people type in the browser. Press Enter, or pick another name under your domain such as ats.example.com.
Hostname of the sign-in portal (Authelia) [auth.example.com]
The sign-in page. It has to be under the same domain as the app hostname (the sign-in cookie is shared through it). Press Enter.
Public intake hostname for your website's applications (none = no public intake) [apply.example.com]
Where your website delivers applications. Press Enter. Type none only if your website runs inside your own network; it then posts to the app hostname instead.

Network#

Internal IP staff reach this server on (LAN or Tailscale)

goose first lists the addresses it found, labeled LAN or Tailscale, and suggests the Tailscale one if there is one. Caddy listens on this one address only, never on all of them.

Everyone in the office: the LAN address. Some people at home: the Tailscale address, with everyone on Tailscale (or keep the LAN address and let a Tailscale subnet router reach it).

HTTPS port [443]
Press Enter. Change it only if something else already uses 443 on that address; staff then open https://hire.example.com:8443/ats/ (with your port).
HTTP port (redirects to HTTPS) [80]
Press Enter. It only redirects to HTTPS.

Gooseplicant#

Edition: full (Gooseplicant) or lite (Gosling, the file browser) [full]
full gives you the whole applicant tracker with Gosling alongside. lite runs only Gosling, which files applicants by location and position. You can switch later by running setup again; the data stays. See Gosling.
Docker Compose project name [gooseplicant]
Press Enter. It names the containers and data volumes.

goose does this

Generates every secret: Authelia's session, storage and password-reset keys (in deploy/secrets/), the INGEST_SECRET your website signs applications with, and the PROXY_SECRET that lets Gooseplicant trust only Caddy. They are never shown, except the ingest secret you need for your website.

Sign-in#

Admin username [admin]
Your own sign-in name, for example dana. Lowercase letters, digits, . _ -.
Admin display name [Administrator]
Your name as it appears on the sign-in page.
Admin email (becomes the first Gooseplicant admin)
Your work email. goose puts it in BOOTSTRAP_ADMINS, so you are an admin inside Gooseplicant from the first sign-in.
Password for dana (min. 12 characters; leave empty to generate one)
Type a password twice, or press Enter and goose generates a strong one and shows it once. Put it in your password manager right away. goose stores only an argon2id hash.
Email for Let's Encrypt expiry notices [your admin email]
Press Enter. Let's Encrypt writes here only if a certificate is about to expire without renewal.
Require two-factor sign-in (authenticator app or passkey)? (y/n) [y]
Press Enter (yes). Everyone then confirms each sign-in with an authenticator app or a passkey. n means password only.

Email for sign-in codes#

Send them by SMTP? (no: write them to a file, read with ./goose code) (y/n) [n]

Authelia sends a one-time code when someone registers their two-step device, and a link when someone resets their password. No (the default): the message is written to a file on the server and you read it with ./goose code. Fine for a small team where you're nearby. Yes: goose asks for the mail server:

  • SMTP server, SMTP port (587 STARTTLS, 465 TLS) [587], SMTP username (empty: no login),
  • Sender address (mails come from "Gooseplicant <address>"), SMTP password,
  • Use the same SMTP server for Gooseplicant's own emails? (y/n) [y]: yes lets Gooseplicant send candidate emails and HR notifications too, once you switch those modules on.

Cloudflare#

Cloudflare API token (leave empty to see how to create one)
Paste the token from step 2. goose checks it and finds your domain.
Apply these DNS changes via the Cloudflare API? (y/n) [n]
goose first shows exactly what it would create: an A record for the app and the sign-in hostnames, pointing at your internal IP, DNS only. Answer y to let it. Answer n if you'd rather add them yourself, or keep these names in local DNS only (step 5).
Tunnel name [gooseplicant-intake]
Press Enter.
Create/update the tunnel via the Cloudflare API? (y/n) [n]
goose shows its plan (create the tunnel, send apply.example.com to the intake-only port, everything else 404, and the matching DNS record). Answer y. If the token has no tunnel permission, goose prints the dashboard steps instead and asks you to paste the tunnel's token (step 5).

Connect your website, start#

goose finishes by writing the configuration and printing what your website needs (step 8): ATS_URL and the ATS_INGEST_SECRET. Copy the secret into your password manager now; ./goose connect shows both again whenever you need them. The last question is Start it now? (y/n) [n]: answer y, or n and run ./goose up yourself (step 6).

A complete run#

Here is what it looks like for Fieldstone, with example.com on Cloudflare and a token that has all three permissions. Secrets are replaced by placeholders.

./goose setup (sample)
$ ./goose setup
Gooseplicant one-box setup

==> Hostnames
? Your domain (managed in Cloudflare), e.g. example.com: example.com
? Hostname staff use for Gooseplicant [hire.example.com]:
? Hostname of the sign-in portal (Authelia) [auth.example.com]:
? Public intake hostname for your website's applications (none = no public intake) [apply.example.com]:
  ok    app https://hire.example.com  ·  sign-in https://auth.example.com  ·  intake apply.example.com

==> Network
  Addresses on this machine (Caddy listens on ONE of them, never all):
      192.168.1.20     LAN
      100.101.102.103  Tailscale
? Internal IP staff reach this server on (LAN or Tailscale) [100.101.102.103]: 192.168.1.20
? HTTPS port [443]:
? HTTP port (redirects to HTTPS) [80]:
  ok    Caddy will listen on 192.168.1.20:443 (and :80 for redirects)

==> Gooseplicant
? Edition: full (Gooseplicant) or lite (Gosling, the file browser) [full]:
? Docker Compose project name [gooseplicant]:
  -     generated secrets/authelia_session_secret
  -     generated secrets/authelia_storage_encryption_key
  -     generated secrets/authelia_reset_password_jwt_secret
  -     generated INGEST_SECRET
  -     generated PROXY_SECRET
  ok    edition full, image gooseplicant:0.7.0

==> Sign-in (Authelia, local users)
? Admin username [admin]: dana
? Admin display name [Administrator]: Dana Reyes
? Admin email (becomes the first Gooseplicant admin): dana@example.com
? Password for dana (min. 12 characters; leave empty to generate one):
  ok    created Authelia user dana <dana@example.com> (argon2id)

┌─ SECRET: password for Authelia user dana
│  <generated password, shown only here>
└─ Store it in a password manager. It is not shown again.
? Email for Let's Encrypt expiry notices [dana@example.com]:
? Require two-factor sign-in (authenticator app or passkey)? (y/n) [y]:
  ok    policy two_factor

==> Email for Authelia (one-time codes, password resets)
? Send them by SMTP? (no: write them to a file, read with ./goose code) (y/n) [n]:
  ok    filesystem notifier: ./goose code shows the latest one-time code or reset link

==> Cloudflare: certificates, DNS and the public intake tunnel
  Caddy gets certificates with the ACME DNS-01 challenge at Cloudflare, so the hostnames can stay internal.
  It needs an API token with Zone · Zone · Read + Zone · DNS · Edit (and optionally Account · Cloudflare Tunnel · Edit).
? Cloudflare API token (leave empty to see how to create one):
  ok    token is valid and active
  ok    zone example.com (<zone-id>)
  goose can make these DNS changes in Cloudflare:
      create  hire.example.com                   A 192.168.1.20 (DNS only)
      create  auth.example.com                   A 192.168.1.20 (DNS only)
  -     192.168.1.20 is a private address: the names become public, the server does not.
? Apply these DNS changes via the Cloudflare API? (y/n) [n]: y
  ok    created A hire.example.com → 192.168.1.20
  ok    created A auth.example.com → 192.168.1.20
? Tunnel name [gooseplicant-intake]:
  goose can set up the public intake in Cloudflare:
      create  tunnel gooseplicant-intake        (remotely managed, cloudflared runs here)
      set     ingress apply.example.com (path ^/ats/(ingest|api/health)$) → http://gooseplicant:3001, anything else → 404
      upsert  CNAME apply.example.com → <tunnel-id>.cfargotunnel.com (proxied)
? Create/update the tunnel via the Cloudflare API? (y/n) [n]: y
  ok    created tunnel gooseplicant-intake (<tunnel-id>)
  ok    tunnel ingress: apply.example.com → http://gooseplicant:3001 (everything else 404)
  ok    created CNAME apply.example.com → <tunnel-id>.cfargotunnel.com (proxied)
  ok    tunnel token stored in deploy/.env (TUNNEL_TOKEN)

==> Rendering configuration
  ok    config/caddy/Caddyfile, config/authelia/configuration.yml, config/gooseplicant.env, config/authelia.env
  ok    Authelia configuration valid (authelia/authelia:4.39.28 validate-config)

==> Connect your website
  Your website's server posts each application, HMAC-signed, to https://apply.example.com/ats/ingest
  (through Cloudflare and the tunnel; nothing else of Gooseplicant is reachable from the internet).
  Set two values on the website (server-side only; never in the browser or in git):
      ATS_URL           = https://apply.example.com/ats

┌─ SECRET: ATS_INGEST_SECRET (the website signs applications with it)
│  <64 hex characters>
└─ Shown by ./goose connect whenever you need it again.
  Example, a Cloudflare Pages site (run in the website's project):
      npx wrangler pages secret put ATS_INGEST_SECRET --project-name <your-pages-project>   # paste the secret
      # wrangler.toml
      [vars]
      ATS_URL = "https://apply.example.com/ats"
  Request format and a ~40-line reference client: https://ats.goosenotes.dev/docs/connect-a-form/

==> Next
  ./goose up        build the images and start everything (first build: a few minutes)
  ./goose doctor    check DNS, certificates, sign-in, the app and the intake
  ./goose add-user  more staff accounts (then invite them in Gooseplicant → Settings → Users)
  Then open https://hire.example.com/ats/ and sign in as dana.
  First sign-in asks you to register an authenticator app; Authelia sends a confirmation code first: read it with ./goose code.
? Start it now? (y/n) [n]: y

Running setup again later also asks Set a new password for dana?, whether to replace the stored Cloudflare token (Replace it?) and whether to re-check an existing tunnel; the defaults (n) keep what you have.

5. Only if goose couldn't: DNS records and the Tunnel#

Skip this step if setup said created A … and tunnel token stored. If you answered n, or the token lacked a permission, goose printed an ACTION NEEDED box with these same steps and your own values.

DNS records for the internal names#

You do this

Cloudflare dashboard → example.com → DNS → Records → Add record, once per row:

TypeNameContentProxy status
Ahire.example.com192.168.1.20DNS only
Aauth.example.com192.168.1.20DNS only

Content is your server's internal IP (the one you gave setup). Proxy status DNS only is the grey cloud; leave TTL on Auto. Then check with ./goose doctor (after step 6).

Why "DNS only" and a private address? Staff connect straight to your server, and Cloudflare can't proxy to a private address. The address is private, so nobody outside your network or tailnet can connect even though the name is public: a public record with a private address reveals the name, not the server.

Rather not publish them? Skip the records and add the same names to your local DNS instead: your router, Pi-hole, or Tailscale split DNS for your domain. Certificates still work, because Let's Encrypt only needs the temporary record Caddy creates at Cloudflare. Some routers drop public answers that contain private addresses ("DNS rebinding protection"); allow your domain there, or use local DNS.

The Tunnel for the public intake#

You do this

  1. Open one.dash.cloudflare.com → Networks → Tunnels → Create a tunnel.
  2. Connector type Cloudflared → Next. Name it gooseplicant-intake → Save tunnel.
  3. Under Install and run connectors pick Docker. The command shown ends with --token eyJ...: copy only that token (the long string after --token). Don't run the command: goose runs cloudflared.
  4. Next → Public hostname (Route traffic):
    • Subdomain: apply · Domain: example.com · Path: ^/ats/(ingest|api/health)$
    • Service type: HTTP · URL: gooseplicant:3001
    → Save tunnel. Cloudflare adds the proxied CNAME apply.example.com → <tunnel-id>.cfargotunnel.com itself.
  5. Paste the token when ./goose setup asks Paste the tunnel token (leave empty to do this later), or run ./goose setup again later. Then ./goose up starts cloudflared, and ./goose doctor checks the tunnel.

The path rule means the Tunnel only ever forwards the two intake addresses; everything else gets a 404 at Cloudflare. Behind it, port 3001 serves nothing but signed applications anyway.

6. Start it and check it#

Terminal
./goose up
./goose doctor

goose does this

up builds the Gooseplicant image and a Caddy image with the Cloudflare DNS plugin (the first time this needs internet and takes a few minutes), starts Gooseplicant, Authelia, Caddy and, with a tunnel token, cloudflared, and waits until they report healthy. Only Caddy is published, on your internal IP. Caddy then asks Let's Encrypt for the certificates, which takes up to a minute or two.

doctor checks the whole chain and prints one line per check:

  • PASS: fine.
  • WARN: works, but worth a look (for example a certificate that expires within 14 days, or a clock a little off).
  • FAIL: broken. Each warning and failure comes with a fix: line saying what to do. doctor exits with an error if anything failed.
  • SKIP: not applicable to your setup.
./goose doctor (abridged)
==> goose doctor (production mode)
  PASS  deploy/.env is private (600)
  PASS  Authelia secrets present
  PASS  Cloudflare API token configured
  PASS  gooseplicant is running
  PASS  authelia is running
  PASS  caddy is running
  PASS  cloudflared is running
  PASS  Gooseplicant :3000 health 200 {"ok":true}
  PASS  Gooseplicant :3001 health 200 {"ok":true}
  PASS  gooseplicant publishes no ports on the host
  PASS  caddy only on 192.168.1.20:443 192.168.1.20:80
  PASS  DNS hire.example.com → 192.168.1.20
  FAIL  DNS auth.example.com doesn't resolve here
        fix: add A auth.example.com → 192.168.1.20 (DNS only) in Cloudflare or your local DNS; ./goose setup can do it
  PASS  unauthenticated /ats/ redirects to the sign-in portal
  PASS  forged identity headers are refused (302)
  PASS  cloudflared tunnel connected
  PASS  public intake host serves nothing but intake (404 for /ats/, /ats/lite/, the API)
  PASS  unsigned POST /ats/ingest refused (401)
  PASS  clock within 1s of Cloudflare
  …
1 failed, 0 warning(s)

The usual first-run failures:

doctor saysWhat to do
DNS … doesn't resolve here or points elsewhereAdd or fix the A record (step 5). New records can take a few minutes to show up. If your router hides private answers, allow your domain there or use local DNS.
no certificate for …Give Caddy a minute after the first up, then run doctor again. Still failing: ./goose logs caddy and look for obtain or challenge; usually the token is missing Zone · DNS · Edit on that zone. See Troubleshooting.
… is not running./goose up, then ./goose logs <service> for the reason.
public intake … has no tunnel token (WARN)Finish the Tunnel (step 5) and run ./goose setup again to paste its token.
cloudflared not connected./goose logs cloudflared: a wrong token, or the network blocks outbound port 7844.
clock is …s offTurn on time sync (timedatectl set-ntp true on most Linux). Two-step codes and signed applications allow little drift.

7. First sign-in#

You do this

  1. On a computer in the office (or on Tailscale), open https://hire.example.com/ats/. You land on the sign-in page at auth.example.com.
  2. Sign in with your admin username and password.
  3. With two-step on, Authelia asks you to register a second factor. Before it shows the QR code it confirms it's you with a one-time code: with SMTP it arrives by email; without, run ./goose code on the server and type the code it shows.
  4. Scan the QR code with an authenticator app (or register a passkey) and enter the 6-digit code it shows.
  5. You're in Gooseplicant as an admin. Gosling is at /ats/lite/ (with EDITION=lite, /ats/ takes you there).
./goose code (abridged)
$ ./goose code

==> Latest Authelia notification
… the message Authelia would have emailed …
One-time code: <8 letters and digits>

You're an admin because your email is in BOOTSTRAP_ADMINS, which goose set from the admin email in step 4. Nobody else is, until you invite them (step 9).

8. Connect your website#

Your careers website keeps its form. After it accepts a submission, its server sends a signed copy to Gooseplicant. It needs two values, which ./goose connect prints again any time:

website settings (server-side only)
ATS_URL=https://apply.example.com/ats
ATS_INGEST_SECRET=<the secret setup showed; ./goose connect prints it again>

You do this

A Cloudflare Pages site: in the website's project, store the secret and set the URL:

Terminal
npx wrangler pages secret put ATS_INGEST_SECRET --project-name <your-pages-project>   # paste the secret
wrangler.toml
[vars]
ATS_URL = "https://apply.example.com/ats"

Any other host (Netlify, Vercel, a PHP or Node server, your web agency's setup): add the same two values as server-side environment variables or secrets in its settings, then add the signing code from Connect a form to the form's server code. Never put the secret in browser JavaScript or in git.

Then test it: submit a real application on your careers page, with a resume attached. It shows up in the Gooseplicant inbox within seconds.

To check the intake from anywhere, curl -s https://apply.example.com/ats/api/health answers {"ok":true}; a signed test submission is in the curl recipe. If you answered none for the intake hostname, your website must run inside your network and posts to https://hire.example.com/ats/ingest instead (ATS_URL=https://hire.example.com/ats).

9. Add your team#

Adding someone takes two steps, on purpose: Authelia decides who can sign in (an account with a password and two-step), and Gooseplicant decides what they may do once signed in (viewer, recruiter or admin, and which sites). Someone with only the first step sees "Ask an admin to invite you".

Terminal
./goose add-user

It asks for a username, display name, email (it must be the address you invite in Gooseplicant), groups (optional, default staff) and a password: type one, or press Enter to have one generated and shown once. Authelia picks up the new account by itself. Without questions:

Terminal
./goose add-user sam --name "Sam Ortiz" --email sam@example.com

You do this

  1. In Gooseplicant, open Settings → Users & access → Invite, enter the same email, and pick a role and sites. In Gosling: Admin → Users. (goose prints the direct link, like https://hire.example.com/ats/#/settings/users.)
  2. Give the person their username and password through a safe channel. They register their own two-step device at their first sign-in.
CommandWhen
./goose passwd samSet a new password (asks twice, or generates one).
./goose reset-2fa samLost phone: removes their authenticator app and passkeys; they register again at the next sign-in. A new password alone doesn't reset two-step.
./goose remove-user samThey can't sign in any more. Also remove them in Gooseplicant under Users & access.

People can also reset a forgotten password themselves from the sign-in page; the reset link goes by email with SMTP, or you read it with ./goose code.

10. Add a second internal domain#

A second company or brand with its own domain can reach the same Gooseplicant:

Terminal
./goose add-domain hire.fieldstone.example

goose does this

  • Caddy serves the new name (and requests its certificate), and Authelia lets signed-in people use it.
  • A name under another domain gets its own sign-in portal on that domain, auth.fieldstone.example (choose another with --auth-host), because a sign-in cookie can't cross domains.
  • It offers the A records for the new names, through the API or as an ACTION NEEDED box (--yes creates them without asking).
  • If the stack is running, it reloads Caddy and restarts Authelia (people signed in at that moment sign in again).

You do this

  1. The new domain must be a zone in your Cloudflare account, and the API token must include it: dash.cloudflare.com/profile/api-tokens → your token → Edit → Zone Resources → add the zone.
  2. Then: ./goose restart caddy && ./goose doctor

A second name under the same domain, like ./goose add-domain ats.example.com, simply shares the existing sign-in portal. Either way PUBLIC_URL stays your first hostname, so links in emails always use https://hire.example.com.

11. Day to day: updates, backups, logs#

Stay current: ./goose update#

Terminal
./goose update --check      # installed vs. latest, and what's new; changes nothing
./goose update              # shows what's new, asks, updates this folder in place

goose does this

Downloads the newer kit from this site and checks its SHA-256 against manifest.json and SHA256SUMS, shows the release notes since your version and asks. It builds the new images and checks the new configuration in a staging copy while the current version keeps running, then backs up (data, two-step registrations, settings and the current kit), replaces the kit's files in this folder (never .env, config/, secrets/, backups/ or files you added), starts everything, waits for health and runs doctor. If the new version doesn't come up, it rolls back to the old one and its data by itself. Every run is logged in deploy/backups/update.log.

The folder keeps its name, so paths, scripts and schedules keep working. --yes skips the question (needed without a terminal), --to <version> picks a specific newer version, --no-rollback leaves a failed version in place for inspection. Step by step, with exit codes and what to do when an update fails: Upgrading.

Terminal
./goose rollback            # back to the version and the backup from before the last update

rollback shows when that backup was taken and asks first: applications, edits, users and two-step registrations since then are lost. It keeps one step of history.

Scheduled updates#

Terminal
./goose update --schedule weekly     # Sundays at 03:17 local time; or: daily
./goose update --schedule status
./goose update --schedule off

goose shows the crontab line it adds for your user and asks (--yes adds it without asking). Scheduled runs pass --yes, back up, roll back by themselves when needed and append to deploy/backups/update.log. Without a crontab command, goose prints a systemd timer and a launchd job to set up instead. On a Mac, cron can't read Desktop, Documents or Downloads unless /usr/sbin/cron has Full Disk Access: keep the kit elsewhere, like ~/gooseplicant.

You do this

Nothing, unless an update fails. Then the old version is running again (exit code 2), and the failed version's logs are in deploy/backups/update-failed-<version>-<id>.log, next to update.log. Read them, try again later, or ask for help with that log.

Backups#

Terminal
./goose backup                          # into deploy/backups/
./goose backup /mnt/nas/gooseplicant    # or another folder

One gooseplicant-backup-<date>.tar.gz with a consistent snapshot of the database and all files, Authelia's two-step registrations, and your settings and secrets (deploy/.env, config/, secrets/). It holds secrets, so it is readable only by you; copy it off the machine and protect it like the live data. Schedule it with cron or a systemd timer. ./goose update keeps the folder, so the schedule keeps working; only after the one-time side-by-side upgrade from 0.4 does it need the new folder.

Terminal
./goose restore deploy/backups/gooseplicant-backup-20261014-020000.tar.gz

restore replaces the current data and settings with the backup and restarts everything. It asks first; --yes skips the question.

Still on 0.4? One upgrade, then update#

0.4's goose has no update. Upgrade side by side once; the new folder then updates in place:

Terminal
cd gooseplicant-0.4.0/deploy
./goose upgrade 0.7.0
cd ../../gooseplicant-0.7.0/deploy
./goose update --check

upgrade downloads and verifies the new kit, backs up, unpacks it next to the current one, carries over .env, config/ and secrets/, rebuilds, restarts and runs doctor. The old folder stays for rollback until you delete it. Details: Upgrading from 0.4. (upgrade is still in 0.5's goose, with --files-only and upgrade-finish, but update replaces it.)

Status, logs, stop#

Terminal
./goose status              # containers, URLs, edition and version
./goose logs caddy -f       # follow one service: gooseplicant, authelia, caddy, cloudflared
./goose restart authelia
./goose down                # stop everything (data is kept)

./goose down --volumes deletes all data: applications, files, two-step registrations and certificates. It asks first.

12. Troubleshooting#

An update rolled back with "doctor reported FAIL after the switch"#

Up to 0.6.0, ./goose update rolled back on any doctor FAIL after the switch, including problems that were already there before the update, such as a missing DNS record for the public intake hostname. The rollback then reported itself as failed for the same reason, even though the previous version was running fine. From 0.6.1, update runs doctor before the switch too and only rolls back for new failures (plus the health checks), and a rollback counts as done when the previous version is healthy again. Just run ./goose update again: the new rules come with the version you update to. If the stack is down after an old rollback, ./goose up starts it.

To clear the usual pre-existing FAIL, "DNS apply.… doesn't resolve": the tunnel is connected but nothing routes the public hostname to it. In Cloudflare Zero Trust → Networks → Tunnels → your tunnel → Public hostname, add the intake hostname with service HTTP → gooseplicant:3001 (path ^/ats/(ingest|api/health)$); Cloudflare creates the proxied CNAME itself. Or run ./goose setup again with a token that has Account · Cloudflare Tunnel · Edit and let goose create it. No public intake wanted yet? Answer none for the intake hostname in ./goose setup.

"Caddy rejected config" with "API token 'x' appears invalid"#

A bug in 0.5.0: goose checked the Caddy config with a placeholder token that the Cloudflare plugin refuses, so setup stopped there even with a correct token. Fixed in 0.5.1. Update with ./goose update, or on a 0.5.0 install that hasn't started yet, patch the script and run setup again (it keeps everything you already entered):

Terminal
sed -i.bak 's/CF_API_TOKEN=x /CF_API_TOKEN=gooseplicantvalidateonly0000000000000000 /' goose
./goose setup
./goose up
./goose doctor

If the message names your real token instead of x, it isn't an API token: paste the token itself (35–50 characters, or starting with cfut_), not the token's ID or the Global API Key. Run ./goose setup and answer y to "Replace it?".

Certificate errors#

No certificate, or obtain errors in ./goose logs caddy: the token lacks Zone · DNS · Edit on that zone, or the hostname's zone isn't in the token's Zone Resources (step 2). timed out waiting for record to fully propagate: the server can't reach Cloudflare's DNS at 1.1.1.1 (Caddy checks the record there); allow outbound DNS to it. Rate limited after many tries: wait an hour; ./goose doctor shows which names already have a valid certificate.

Redirect loop, or "There was an issue retrieving the current user state"#

The app hostname isn't under the domain of its sign-in portal (for example app hire.example.com, portal auth.example.net). Run ./goose setup again with matching names, or use ./goose add-domain for names under another domain. Also check the clock with ./goose doctor.

Signed in, but Gooseplicant says "Ask an admin to invite you"#

The sign-in account works; Gooseplicant just doesn't know the person yet. Invite the same email under Settings → Users & access (or Gosling's Admin → Users), then they click I've been added, reload.

Two-step codes without SMTP, lost phones#

Start the step in the browser first (register a device, reset a password), then run ./goose code: it prints the one-time code or the reset link. Lost authenticator: ./goose reset-2fa <user>.

Docker Desktop on a Mac, or "cannot assign requested address"#

Docker can only listen on an address that exists when Caddy starts: your LAN IP, or the Tailscale IP once Tailscale is up. On a Mac, start Tailscale before Docker Desktop. On Linux, if Docker starts before Tailscale after a reboot, run ./goose up again, or order docker.service after tailscaled.service.

Port 443 already in use#

Run ./goose setup again and choose another HTTPS port (and HTTP port). Staff then open https://hire.example.com:<port>/ats/.

Applications don't arrive (tunnel down)#

./goose doctor checks the tunnel from end to end. cloudflared not connected: ./goose logs cloudflared (wrong token, or outbound port 7844 blocked). public …/api/health fails: in the Tunnel's public hostname, the service must be HTTP gooseplicant:3001, never 3000. apply.example.com points at this server: it must be the proxied CNAME to the tunnel that Cloudflare created, not an A record. Your website's clock must also be right to within a few minutes.

Command reference#

Run them in the kit's deploy/ folder. ./goose help prints the short version.

CommandWhat it does
setupAsks the questions, generates secrets, writes config/, does the Cloudflare steps it may, prints the website connection. Safe to re-run.
setup --import-users <file>Also imports an existing Authelia users_database.yml (password hashes kept).
setup --config <answers.env>Answers every question from a file instead of asking (below).
setup --testTry-out mode: Caddy's own certificate authority instead of Let's Encrypt, no Cloudflare, hire.goose.test / auth.goose.test on 127.0.0.1:18443 by default.
setup --productionTurns a test install into a real one (secrets and users kept).
up / up --buildStart everything (and rebuild the images); waits until healthy.
down / down --volumesStop everything / stop and delete all data (asks first).
restart [service]Restart one service or all.
logs [service] [-f]The last 200 log lines; -f keeps following.
statusContainers, URLs, mode, edition and version.
add-user [name] [--name N] [--email E] [--groups a,b] [--password-stdin]A sign-in account (argon2id). Then invite the email in Gooseplicant.
passwd <user> [--password-stdin]Change a password.
remove-user <user> [--yes]Delete a sign-in account.
reset-2fa <user>Delete someone's authenticator app and passkeys; they register again.
add-domain <host> [--auth-host H] [--cookie-domain D] [--yes]Another hostname for the app; --yes creates its DNS records without asking.
doctorPASS / WARN / FAIL per check, each with a fix; exits with an error on any FAIL.
codeThe latest Authelia message (one-time code, reset link) when there's no SMTP.
connectThe website settings again: ATS_URL and ATS_INGEST_SECRET.
renderRewrite config/ from .env and templates/; apply with up. See Configuration.
validateCheck the rendered Authelia and Caddy configs without starting anything (0.5.1+).
backup [dir]Snapshot of data, two-step registrations, settings and secrets as one tar.gz.
restore <file> [--yes]Put a backup back and restart.
update [--check] [--yes] [--to <version>] [--no-rollback]The way to update, in place: download and verify, release notes, build in a staging copy, back up, switch, start, health, doctor; rolls back by itself on failure (above). Exit codes: 0 updated or up to date · 10 --check: an update is available · 1 error, nothing changed · 2 failed and rolled back · 3 failed, not rolled back.
update --schedule daily|weekly|off|status [--yes]Unattended updates from your crontab at 03:17 local time (weekly: Sundays); status shows the entry and the last update, off removes it.
rollback [--yes]Back to the kit and the backup from before the last update (later changes are lost). One step only.
upgrade [version|latest] [--files-only] [--yes]The older, side-by-side way (what 0.4 has): download, verify, back up, install into ../gooseplicant-<version>, restart, check. The old folder stays.
upgrade-finishRun by upgrade in the new kit; by hand after --files-only.
versionThe kit, Authelia and Caddy versions.

Unattended setup (answers file)#

./goose setup --config answers.env reads KEY=value lines instead of asking. A question without a key takes its default; a question without a default (like BASE_DOMAIN or ADMIN_EMAIL) must be answered. Keep the file private: it can hold passwords and tokens.

answers.env
BASE_DOMAIN=example.com
# APP_HOST=hire.example.com   AUTH_HOST=auth.example.com   INTAKE_HOST=apply.example.com (or none)
BIND_IP=192.168.1.20
# HTTPS_PORT=443   HTTP_PORT=80   EDITION=full   COMPOSE_PROJECT_NAME=gooseplicant
ADMIN_USER=dana
ADMIN_NAME=Dana Reyes
ADMIN_EMAIL=dana@example.com
# ADMIN_PASSWORD=...   (leave out to have one generated and printed once)
# ACME_EMAIL=dana@example.com
REQUIRE_2FA=yes
SMTP=no
# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SENDER, APP_MAIL when SMTP=yes
CF_API_TOKEN=<your Cloudflare API token>
CF_CREATE_DNS=yes
CF_CREATE_TUNNEL=yes
# TUNNEL_NAME=gooseplicant-intake   TUNNEL_TOKEN=... (a tunnel you made in the dashboard)
START=yes

Want to set things up by hand instead, or behind a proxy you already run? See Docker + Compose, Bare Node.js and Public intake, private app.