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), 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.
  • Webhooks, API keys and ATS targets. If you point a webhook at Zapier, Make or an ATS, or hand an API key to one, that service becomes your recipient or processor (possibly a third-country transfer), and erasure in Ganek does not reach its copies. Sign its DPA and add it to your records; the ATS handoff page lists what the payload carries.

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, interview invites by email, 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 2FA and webhook secrets.
  • 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. Ganek has no built-in third-country flow (§6); don’t add one 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, their answers to the screening questions, 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, the rejection reason and note where one was recorded, note bodies, the delivery log of every email sent to them (kind, status, timestamps), the activity entries of each application, 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.

Scorecards are disclosable too. The access export includes submitted scorecard ratings, recommendation and comment, without the reviewer’s name. Write them the way you write notes.

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 the database records, leaving one anonymized count-only receipt in the activity log. For a booked future interview the interviewer gets a calendar cancel notice without candidate data, so the event leaves their calendar. Honest limits - copies Ganek cannot reach:

  • Emails already sent (in the candidate’s and your mailboxes), including earlier calendar invites the interviewer accepted by hand.
  • 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.
  • Copies held by a webhook, Zapier, Make or ATS target you connected.
  • 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; repeat clicks do not stack while a task for that candidate and request kind is open) 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 activity entry records that a request was received, but it belongs to the application and goes when the candidate is erased; only the count-only erasure receipt outlives them.

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. A reviewer opening a candidate’s judging page is recorded the same way (view, CV download, quiz answers); their candidate list is not. 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, display name, saved interview availability and default meeting place wiped, their open scorecard requests dismissed and their name removed from scorecard activity, 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: other 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. In multi mode, signups that never verified their email are deleted after 7 days.
  • 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.

6. Interview invites: no calendar provider

Section titled “6. Interview invites: no calendar provider”

Interview scheduling needs no calendar connection. Slots come from the recruiter’s saved weekly availability; when a candidate books, Ganek emails both sides a calendar invitation (.ics) with the candidate’s name and email, the time and the meeting place or video link the recruiter entered. A reschedule or cancellation sends the matching update, and erasing a candidate with a booked future interview sends the interviewer a cancel notice that carries no candidate data.

For your records: the only recipients are your email provider (already in your inventory) and the people on the invite. No third-country leg is built in. The Google Calendar integration that used to exist was removed on 2026-10-31.

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.