Skip to content

Self-hosting Ganek under the GDPR

Not legal advice - see the pack README. This guide maps GDPR duties onto the controls Ganek actually ships, so “comply” becomes a list of things you can point at.

When you self-host Ganek, your company decides why and how candidate data is processed - that makes you the data controller (Art. 4(7)). Ganek (the project) has no role in your deployment: it never sees your data, exactly like your database or web server don’t make their vendors controllers.

Concretely, being the controller means:

  • Candidate-facing notices name your company. Ganek renders them from your settings; the words are yours.
  • Data-subject requests (access, deletion, rectification, objection) are yours to answer, within one month (Art. 12(3)). Ganek gives you the buttons; you own the response.
  • Your lawful bases, retention choice, and security posture are yours to document - the templates in this pack pre-fill the Ganek-shaped parts.
  • If you engage others to process the data (hosting provider, email relay, Google for interviews), they are your processors/recipients and belong in your records.
  • Error reporting is off by default. If you set GANEK_SENTRY_DSN, the Sentry host you point it at (Sentry’s cloud, or a self-hosted Sentry/GlitchTip) becomes a processor of error reports. Ganek scrubs emails, capability tokens, CV file names and cookies from every event before it leaves the box and never enables Sentry’s default PII collection, but stack traces can still carry fragments of candidate data. Prefer an EU region or your own instance, sign its DPA, and add it to your records.

Not established in the EU but hiring EU candidates? You are likely in territorial scope via Art. 3(2) and may need an EU representative (Art. 27). This pack doesn’t cover that appointment - check it before targeting EU applicants.

Work through this once, before the careers page goes public.

Identity & notice

  • Fill Settings → Privacy in the admin app: company legal name, privacy contact email, retention period, and (optionally) a URL to your own privacy policy. Unset fields fall back sensibly - the legal name falls back to your display name - but set them deliberately.
  • Open your public privacy notice (linked from the careers-page footer; /c/{your-slug}/privacy, or /privacy in single-company mode) and read it as a candidate would. It renders your settings values, including the retention promise. If you set your own policy URL instead, make sure your policy actually covers everything the built-in notice does - quiz scoring, integrity telemetry, the Google Calendar leg, retention, and candidate rights.

Retention

  • Choose the retention window consciously (Settings → Privacy, 1-24 months, default 6). It is the number your notice promises and the purge enforces. Member-state expectations differ - pick for the strictest regime you hire in: - France (CNIL): maximum 2 years after last contact. - Germany: ~6 months is common practice (AGG §15 discrimination-claim window plus margin). - Poland (UODO): stricter - guidance leans toward deleting when the recruitment ends; blanket keep-for-claims justifications are viewed skeptically. Shorter is safer. - UK (ICO): 6-12 months typical.

Deployment hardening

  • Work through the production hardening checklist at the bottom of .env.example - it exists because candidate PII lives in this stack. In short: HTTPS everywhere with GANEK_COOKIE_SECURE=true (quiz, status, and interview links are bearer tokens; plain HTTP exposes them), TLS SMTP relay, change the MinIO defaults and don’t expose its ports, keep the backend behind your reverse proxy, Redis AUTH outside the bundled compose network, and consider a separate GANEK_ENCRYPTION_KEY for stored Google credentials.
  • Turn on two-factor authentication for every admin account (Settings → Account, any TOTP authenticator app; keep the recovery codes offline). The same card signs out every other session if a laptop goes missing. Enforcing it per workspace is a later ee/ policy.
  • Set up SPF, DKIM and DMARC on the sending domain and, with Mailjet, the bounce/complaint webhook (see “Email deliverability” in §3). A quiz invite in spam is a candidate you never hear from.
  • Enable encryption at rest on your storage layer. The app sets no S3 server-side-encryption headers by design - use MinIO KMS/SSE, disk encryption, or a provider that encrypts by default, for both the CV bucket and the Postgres volume.
  • Host in the EU/EEA if you can. The only built-in third-country flow is Google Calendar (§6); don’t add more by picking US hosting for the database and CV bucket.
  • Decide your backup policy before you hold candidate data: encrypted backups, a rotation window at or below your retention period, and restores only through scripts/restore.sh, which re-applies erasures (see “Backups and erasure” in §3). Completing the rotation is what completes an erasure inside the backups themselves.
  • Your reverse proxy and host keep their own logs. Ganek’s bundled compose scrubs capability tokens from access logs and rotates them - apply the same discipline (don’t log the full URLs of quiz, status, interview, or invite links - the token is the credential; keep retention bounded) at any layer you add in front.

Access & portability requests (Art. 15/20). Open the applicant, use the ⋯ → Export data (JSON) action, and hand the candidate the bundle plus the CV file. The bundle contains everything Ganek stores about them: identity, all their applications and messages, quiz attempts with scores, per-question answers (with correctness, never the answer key), integrity events and flags (with your staff’s identifiers scrubbed), interview records, note bodies, and a CV download link. Deliver it within one month of the request. Exports are deliberately admin-mediated - there is no candidate-side bulk download behind the status link, so a leaked link can never become a PII dump.

Recruiter notes are disclosable. Notes about a candidate are that candidate’s personal data and are included in the export (bodies, without author names). The notes UI reminds your team; make it culture: factual, professional, nothing you wouldn’t show the person.

Rectification (Art. 16). A candidate re-applying updates their name and links. Their email - the delivery channel for every quiz/status link - is fixed via ⋯ → Edit candidate email… on the applicant.

Erasure (Art. 17). ⋯ → Erase candidate… deletes the person across all their applications: CV files first (the erasure aborts rather than leave an orphaned CV), then best-effort deletion of any Google Calendar events (failures are counted and shown - clean those up in the calendar by hand), then the database records, leaving one anonymized count-only receipt in the activity log. Honest limits - copies Ganek cannot reach:

  • Emails already sent (in the candidate’s and your mailboxes).
  • Google Calendar events where deletion failed (reported to you).
  • Free text your team typed into your own tasks (manual tasks aren’t linked to candidates - check them after an erasure). The privacy-request tasks Ganek itself creates are handled for you: erasing a candidate deletes their request tasks, and completed request tasks are swept by the nightly job 90 days after they’re marked done.
  • Your backups (covered by your rotation rule, §2) and any logs kept by layers you run in front of Ganek.

You may defer erasure while your retention window (claims defense, Art. 17(3)(e)) still runs - that’s what the automatic purge is for. “Erase now” is for when someone asks and you have no live reason to keep the data.

Candidate-initiated requests. The status page every candidate gets carries “Request a copy of my data” and “Ask for my data to be deleted”. Each creates a task on your dashboard (due in 14 days, deduped per application) with the candidate’s name and email in the note - the task is the request; there is no auto-deletion. Answer like any other Art. 15/ 17 request, within the month. The task doesn’t outlive the person it names: erasing the candidate removes it, and done request tasks are swept 90 days after completion (the PII-free activity entry is the lasting record that a request was received).

Withdrawals. A candidate withdrawing moves the application to withdrawn - a pipeline state, not an erasure. The status page tells them data is kept for your retention window unless they request deletion. The purge clock starts at the withdrawal.

Who looked at a candidate. Ganek keeps a read-access trail: opening the profile, the CV download link, the quiz answers and the data bundle each record who did it (a teammate or an API key) and when; repeats inside ten minutes count once. Admins see it as the “Access” card on the applicant and company-wide under Settings -> Privacy -> Access log, filtered by teammate or action, with a CSV download that is itself written to the activity log. Members are recorded but do not see either. The applicant list is not logged. Use it for Art. 5(2) accountability and for answering “who had access” after an incident. It is not part of the candidate’s bundle (Art. 15 covers recipients as categories, not colleague names) and it goes with the application on erasure.

Offboarding recruiters. You are also the controller for your own staff’s accounts. Team → ⋯ → Remove & anonymize… tombstones the user: email replaced with a random placeholder, password and Google sign-in severed, saved interview availability wiped, calendar connection revoked and deleted, account deactivated and sessions cut. It refuses while the user still has upcoming interviews (cancel those first - there is no reassign) or is the last admin. One honest limit: activity-log entries written while they worked keep the staff email snapshotted in their payloads at the time (e.g. an interview entry records which interviewer email it was scheduled with), so the hiring trail stays legible - factor that into what you promise departing staff.

Breach. Read the breach runbook once now, not during the incident.

Backups and erasure. scripts/backup.sh writes a database dump and a tarball of the CV bucket; scripts/restore.sh puts them back. The catch with any backup is Art. 17: a dump taken on Monday still contains the candidate you erased on Tuesday, and a naive restore on Wednesday would resurrect them. Ganek handles this with erasure tombstones: every erasure (an admin’s “Erase candidate”, the nightly retention purge, a restore replay) leaves a row holding only a one-way hash of the erased record’s random id - no name, no email, nothing that identifies anyone, so the table is safe to keep in every backup. restore.sh saves the live tombstones before it restores, merges them back afterwards, and then replays them: any restored application or candidate whose id matches a tombstone is erased again, CV file first, exactly as the original erasure ran. The count-only candidate.erased receipts in the activity log are unaffected. In plain words:

  • Erase someone, restore an older backup, and they are gone again by the time the restore script finishes; you do not have to remember whom you erased.
  • Retention still interplays: a restore brings back applications whose retention clock had already expired, and the next nightly purge removes them again (their tombstones from that earlier purge replay immediately anyway).
  • The backups themselves still hold the erased data until they rotate out. Keep the rotation window at or below your retention period and encrypt the files; that rule is yours to run, the scripts cannot do it for you.
  • Never restore by hand with pg_restore alone. Use restore.sh, or at least run python -m app.services.tombstones in the backend container afterwards.

Email deliverability. Every candidate email (quiz invite, status update, interview link) is a transactional message from the address in GANEK_EMAIL_FROM, and a quiz invite that lands in spam is a candidate you never hear from. Three DNS records on the sending domain decide most of that, whichever transport you use:

  • SPF: a TXT record on the domain listing who may send for it. For a relay you run yourself: v=spf1 ip4:<relay-ip> -all; for Mailjet: v=spf1 include:spf.mailjet.com -all. One SPF record per domain, so merge Ganek’s sender into the record you already have.
  • DKIM: the relay or provider signs each message with a key whose public half lives in DNS (<selector>._domainkey.<domain>). Mailjet shows the record to add under the sender domain’s settings; for a self-run relay, OpenDKIM or Rspamd generate it. Without DKIM, Gmail and Outlook now rate-limit or reject bulk senders outright.
  • DMARC: _dmarc.<domain> TXT, start with v=DMARC1; p=none; rua=mailto:dmarc@<domain> to see reports, then move to p=quarantine once SPF and DKIM both pass for every sender you own.

Transport is GANEK_EMAIL_PROVIDER: smtp (any TLS relay, the default) or mailjet (HTTPS API, EU data centres in Paris; needs GANEK_MAILJET_API_KEY and GANEK_MAILJET_SECRET_KEY). Mailjet is a processor under Art. 28 like your SMTP relay is: list it in your records of processing and sign its DPA. Prove the pipe with the test send on Settings → Privacy after any change.

Bounces and complaints come back only over the provider webhook. Set GANEK_EMAIL_WEBHOOK_SECRET to a long random string, then register https://ganek:<secret>@<your-host>/api/v1/email/webhooks/mailjet in Mailjet’s Event API for the bounce, spam, blocked and unsub events. From then on a hard-bounced, complaining or blocked address goes onto the suppression list and gets no further mail; the applicant panel’s email trail shows “bounced”, “marked as spam” or “skipped: address suppressed” so the recruiter knows to reach the candidate another way. The list stores only a one-way hash of the address (the same pattern as erasure tombstones), so it holds no personal data, needs no erasure hook and is safe inside every backup. With a plain SMTP relay there is no feedback loop: watch the relay’s own bounce mailbox and expect retries to exhaust on their own (three attempts, then “failed” on the trail).

  • Nightly retention purge (03:17 UTC): every rejected or withdrawn application older than your window is erased with the same machinery as the manual button; candidates are removed entirely once their last application goes. The clock starts at the decision (rejection/ withdrawal), not at apply. hired is excluded - an employee’s record transitions to your HR retention rules, out of ATS scope. Months are counted as 31 days, so deletion never happens earlier than the notice promised. A storage failure skips just that application and retries the next night. There is deliberately no off switch (Art. 25 privacy by default) - if you need longer, raise the months and let the notice say so.
  • You can run as many worker containers as you like. Queued jobs are taken by one worker each, the nightly purge claims a once-per-day lock in Redis so it runs exactly once however many workers are up, and the outbox sweep and heartbeat are idempotent.
  • Abandoned CV uploads (file picked, form never submitted) are swept after 24 hours; expired team invites are swept nightly.
  • Status links expire after 30 days, interview booking links after 60 days; quiz links expire at their own deadline and are the only kind that can be re-issued (from the applicant view - a re-issue invalidates the old link). Expired status and booking links simply stop working; there is no re-issue for them.
  • Access logs in the bundled compose are token-scrubbed (capability tokens become [token]) and size-capped (json-file rotation), and application logs reference internal IDs, not candidate emails.
Section titled “5. Legal bases (what to put in your records)”

The pre-filled mapping - details and the full table live in the records template:

Activity Basis
Receiving and reviewing applications Art. 6(1)(b) - steps prior to a contract, at the candidate’s request
Screening quiz Art. 6(1)(b) - part of the application process the candidate enters
Integrity telemetry Art. 6(1)(f) - legitimate interest (assessment fairness); see the LIA
Notes, pipeline, activity log Art. 6(1)(f) - managing recruitment
Post-decision retention Art. 6(1)(f) - defense against legal claims, time-boxed by your window
Transactional emails Art. 6(1)(b)/(f) - not marketing; no ePrivacy opt-in needed
Rate limiting (IPs) Art. 6(1)(f) - abuse prevention

Note what is absent: consent. The apply form deliberately has no “I consent to processing” checkbox - the basis is Art. 6(1)(b), and a forced consent checkbox in a power-imbalanced situation would be invalid (EDPB Guidelines 05/2020) and misleading about the real basis. The form shows a privacy notice instead. Don’t “fix” this by adding a checkbox.

If candidates volunteer sensitive data in their message or CV, you didn’t ask for it (the form nudges them not to include it) - don’t copy it into notes, and weigh it for nothing.

Only relevant if your recruiters connect Google Calendar for interview scheduling. When they do:

  • Booking an interview creates an event on the recruiter’s own Google calendar - your Google tenancy, under the recruiter’s OAuth grant - with the candidate’s full name in the title, their email as an attendee, and a Meet link. Google emails the invitation to the candidate directly.
  • Availability is computed by reading the connected calendar’s free/busy status, and Ganek stores the recruiter’s refresh token encrypted. The candidate data Ganek writes to Google is limited to the interview events it creates. (Recruiters also see their own day’s calendar events on their own dashboard - their data, shown only to them.)
  • For your records: this is a transfer to Google LLC (US). Mechanisms: Google’s data-processing terms incorporate SCCs, and Google is DPF- certified while that framework stands. The candidate-facing booking page and privacy notice already disclose the Google involvement.
  • On erasure, Ganek deletes the events it created best-effort and reports failures; sent invitation emails are out of reach (§3).

Don’t connect calendars if this transfer doesn’t fit your posture - interviews then simply happen outside Ganek.

Answer three questions and write the answer down (there’s a row for it in the records template):

  1. Are you a public authority?
  2. Do your core activities consist of large-scale, regular and systematic monitoring of data subjects?
  3. Do your core activities consist of large-scale processing of special-category or criminal-conviction data?

For a company using Ganek to hire for its own roles, the honest answers are usually no/no/no - hiring is a support activity, not your core business, and Ganek’s telemetry is neither large-scale nor the kind of systematic monitoring the trigger means. Then a DPO is not mandatory; you may still appoint one voluntarily (the full Art. 37-39 duties then apply). If you’re a recruitment business processing candidates at scale as your core activity, get advice - question 2 may genuinely bite.

8. What Ganek never does (cite these in your DPIA)

Section titled “8. What Ganek never does (cite these in your DPIA)”

Product guarantees you can rely on, as shipped:

  • No cookies, no analytics, no fingerprinting on any candidate-facing page (careers, apply, quiz, status, booking) - nothing to consent-banner. Job page views are counted server-side as daily totals per job and source label, with no IP, user agent, cookie or visitor identifier, so there is nothing per person to disclose. Tracked-link names identify campaigns or channels: never name a link after an individual, or that daily count becomes a per-person signal. The embeddable jobs widget is equally clean.
  • No IP addresses or user agents in quiz telemetry - the integrity record is three event types (blur, paste, resize), tied to the open question. No keystrokes, no clipboard contents, no screen capture.
  • No automated decisions: no auto-reject, no auto-advance, no score-based filtering. Scores and flags are shown to humans who see the whole application. (Details: AI Act statement.)
  • Answer keys never reach candidates, timing and scoring are server-side, and every tenant’s data is isolated by query-level tenancy checks.

If your fork changes any of the above, the pack’s analyses need redoing - see the pack README.