GDPR Compliance & Privacy
How doable-server and doable-tracker handle personal data, and what consent gets you.
This document is intentionally plain-English. It describes what we collect, how long we keep it, and which deployment modes meet GDPR "affirmative consent" requirements out of the box.
TL;DR
doable offers two distinct ways to use the analytics stack:
| Mode | What it is | GDPR coverage |
|---|---|---|
| Tracker-only | You host your site anywhere (Vercel, Shopify, own server). You only embed dt.min.js and send events to https://doable.services/api/v1/track. |
✅ Full (with cookie consent enabled) |
| Proxy-routed | Your site runs behind the doable-server gateway (Apache → Flask proxy → backend). | ✅ Full (with cookie consent + proxy data minimization) |
The tracker-only mode is designed to be GDPR-compliant by default: no personal data is retained without explicit consent. One admin checkbox enables the consent banner and the job is done.
The proxy-routed mode requires the additional proxy-level controls (IP truncation, DNT as fallback, consent-aware aggregation) because the proxy necessarily sees every request before the tracker's consent logic runs.
Quick reference: decision precedence
| Signal from visitor | Result |
|---|---|
| Clicked Accept on banner | Full tracking — Accept is explicit consent, overrides DNT |
| Clicked Decline on banner | No tracking, minimal access log only (proxy mode) |
No banner interaction + DNT: 1 header |
No tracking (DNT fallback when no explicit decision) |
| No banner interaction + no DNT | Depends on require_consent: banner shown if required; tracked otherwise |
| Revoked previously-given consent | Reverts to "no decision" state; banner re-shown on next init |
Case 1: Tracker-only sites
What this looks like
Your site (e.g., mysite.com) is hosted wherever you like. You add this to your HTML:
<script src="https://doable.services/static/dt.min.js"></script>
<script>
DoableTracker.init({
trackingId: 'dt_xxx',
apiKey: 'your_api_key'
});
</script>
Your site's server never talks to doable-server. The only communication is:
1. The visitor's browser fetches dt.min.js from doable.services
2. The tracker fetches /api/v1/track/config/<tracking_id> to check consent settings (cached 1h)
3. The tracker posts events to /api/v1/track (only after consent granted)
What doable-server collects for tracker-only sites
| Endpoint | What's logged | Where | Retention |
|---|---|---|---|
GET /static/dt.min.js |
IP, UA, timestamp | Apache access log only | 14 days (logrotate) |
GET /api/v1/track/config/<id> |
IP, UA, timestamp | Apache access log only | 14 days (logrotate) |
POST /api/v1/track (consent denied) |
Nothing (tracker never sends) | — | — |
POST /api/v1/track (consent granted) |
Event JSON, hashed session ID, hashed IP | ExternalAnalyticsEvent |
7 days (raw), indefinite (aggregates) |
POST /api/v1/track/consent (on Accept only) |
Session ID hash, consent text hash, privacy URL, UA hash, timestamp | ConsentLog |
7 years (Art 7(1) audit) |
Critically: the /api/v1/track endpoint bypasses proxy.py → log_access() entirely. No entries are written to AccessLog. No Graylog entries. The only persistent storage is ExternalAnalyticsEvent (raw events) and ExternalAnalyticsDaily (aggregated counters). Both handle IPs as SHA256 hashes, never raw.
What cookie consent adds
Before v2.6.23: tracker fires on every pageview regardless of visitor preference.
As of v2.6.23: admin toggles "Require cookie consent" on the tracking project. Tracker then:
1. Shows a neutral bottom banner with Accept and Decline buttons of visually equal prominence (GDPR Art 7(2) requires this)
2. Stores decision in the dt_consent cookie (SameSite=Lax; Secure; Path=/; Max-Age=31536000). Cookie is used rather than localStorage so the proxy layer can read the same decision.
3. Sends events only on granted; stays silent on denied
4. Creates the session ID only after Accept — before consent, nothing is written to the visitor's device (fixes ePrivacy Art 5(3) compliance)
5. On Accept, posts a lightweight consent_granted event to /api/v1/track/consent for audit trail (Art 7(1) accountability)
6. Shows an always-visible floating revoke icon (bottom-left, 32×32px) so withdrawing consent is as easy as giving it (Art 7(3))
No visitor is tracked in doable's systems without an affirmative Accept click. Decision persists across sessions and browser restarts. Revocable via the floating icon, doable('revoke'), or clearing the cookie.
Why this is GDPR-aligned out of the box
- Affirmative consent — Accept/Decline buttons equally prominent (GDPR Art 7 requires this)
- Granular retention — raw events 7 days, aggregates indefinite (but depersonalized)
- No raw IPs — hashed before storage
- Minimal residual logging — Apache access log entries for the script + config fetch (legitimate interest for operational/security; typical 30-90d rotation)
- Revocation — easy via
doable('revoke'), no separate portal needed
What's NOT covered
- Apache's default access log still records the fact that a visitor requested
dt.min.js(IP, UA). This is standard HTTP server logging, justifiable under legitimate interest for operations and security. To minimize further, configureCustomLogrotation aggressively (e.g., 14-day rotation). - If your site has its own analytics (Google Analytics, etc.) alongside doable-tracker, those are separate concerns.
Case 2: Proxy-routed sites
What this looks like
Your site (e.g., tomasandre.se) routes through doable-server:
Internet → Apache (443) → Flask gateway (5000) → Backend on 192.168.x.x
The proxy sees every request and can inject metadata, enforce auth, rate-limit, and — historically — log everything about every visitor.
What the proxy logs
| Layer | What's captured | Where | Default retention |
|---|---|---|---|
| Apache | IP, UA, Referer, path | /var/log/apache2/doable-server-access.log |
System log rotation |
Flask AccessLog |
IP (full), UA, path, query string, status, response time, user_id if logged in | access_logs table |
Indefinite (pre-v2.7.0) |
Flask RouteAnalyticsDaily |
Daily aggregated browser/OS/device/UTM/referrer counters per route | route_analytics_daily table |
Indefinite |
Flask RoutePerformanceDaily |
Response time percentiles, status code counts | route_performance_daily |
Indefinite |
| Graylog (optional) | Extended headers: Accept-Language, Sec-CH-UA, DNT, referer, timing | Graylog GELF UDP | Per Graylog config |
Important: all of this happens at the gateway — before the tracker ever loads in the visitor's browser. Cookie consent on the tracker alone doesn't prevent the proxy from recording the request.
What v2.7.0 changes (proxy data minimization)
As of v2.7.0, with defaults applied:
- IP truncation (pseudonymization) —
AccessLog.client_ipstores192.168.44.0instead of192.168.44.123. Raw IP is kept only in-memory during the request for ban-check and rate-limiting. Configurable viaPROXY_IP_TRUNCATE(default on). Note:/24truncation is pseudonymization under GDPR Art 4(5), not full anonymization — a residence or small office may still be identifiable. - Consent-aware aggregation — visitor declines the cookie consent banner →
dt_consent=deniedcookie set → proxy reads it and skipsupdate_analytics(). Only minimal access log entry (truncated IP, method, path, status) remains. - DNT as fallback —
DNT: 1header skips analytics aggregation only when no explicit banner decision has been made. Explicit Accept on the banner overrides DNT (the banner is specific and informed; DNT is a general preference). - Retention policy —
AccessLogentries older than 90 days (configurable) are deleted by a scheduled cleanup script. Aggregates kept 2 years (already depersonalized). Consent audit logs kept 7 years (GDPR statute of limitations). - Apache log rotation mandate — deployment must include logrotate config with short retention (default 14d) to minimize residual IP exposure from pre-consent requests (
dt.min.jsand/api/v1/track/config/<id>fetches).
Together, cookie consent + proxy data minimization mean: - Consented visitor → full analytics, as today - Declined visitor → minimal truncated-IP access log only, no browser fingerprinting or UTM tracking stored - DNT visitor with no banner decision → same as declined - DNT visitor who clicked Accept → full analytics (explicit consent wins)
Known limitation: first-request blind spot
On proxy-routed sites with require_consent_proxy=True, the first request from a fresh browser cannot be aggregated — the banner hasn't rendered yet, so there's no cookie to read. Subsequent requests after Accept are aggregated normally. This means cumulative analytics slightly undercount unique visitors, but it's the correct behavior under GDPR (no consent = no tracking).
Why this is GDPR-aligned
- Proportionate data minimization — only data needed for the declared purpose is kept
- IP truncation (pseudonymization) aligns with GDPR Art 4(1) interpretations of personal data (CJEU Breyer)
- Short retention of identifiable data, longer for depersonalized aggregates
- Consent decision honored across both layers (tracker + proxy)
- DNT respected without requiring UI click
Data collected per endpoint (full table)
| Endpoint | Mode | Data collected | Retention | Minimized by |
|---|---|---|---|---|
GET /static/dt.min.js |
Both | Apache access log (IP, UA) | System rotation | Proxy IP truncation (pseudonymization) if routed via proxy |
GET /api/v1/track/config/<id> |
Both | Apache access log (IP, UA) | System rotation | Cached 1h — 1 request per unique visitor per hour |
POST /api/v1/track |
Tracker-only | event_data (full JSON), client_ip_hash, session_id, url, path |
7 days (raw), indefinite (aggregates) | anonymize_ip=true zeroes last octet before hashing; cookie consent gates entirely |
POST /api/v1/track/beacon |
Tracker-only | Same as above | Same | Same |
| Proxied request (any) | Proxy-routed | AccessLog + analytics aggregation |
90d / 2y (v2.7.0+) | IP anonymized, consent-aware, DNT-aware |
Comparison with other analytics
| Tracker | Minified size | Network requests per pageview | Consent UX | Data minimization | Self-hosted analytics backend |
|---|---|---|---|---|---|
| Google Analytics 4 | ~50 KB | 2-4 | Requires CMP integration (Cookiebot, OneTrust, etc.) | Google keeps raw data | No |
| Matomo (self-hosted) | ~25 KB | 1-2 | Has consent mode, integration required | Full control | Yes |
| Plausible | ~1 KB | 1 | Cookie-less by design (uses fingerprint hash) | No raw IPs, minimal data | Yes |
| doable-tracker | ~5 KB | 1 (cached) | Built-in, one admin checkbox | Hashed IPs, 7d raw retention | Yes (doable-server) |
doable-tracker sits between Plausible's minimalism and Matomo's full-feature approach, with consent being a toggle rather than a separate integration.
Retention policy (defaults)
| Data | Default retention | Configurable via |
|---|---|---|
AccessLog (proxy requests) |
90 days (v2.7.0+) | ACCESS_LOG_RETENTION_DAYS |
RouteAnalyticsDaily (aggregates) |
2 years | ANALYTICS_RETENTION_DAYS |
RoutePerformanceDaily |
2 years | ANALYTICS_RETENTION_DAYS |
ConsentLog (Art 7(1) audit trail) |
7 years | CONSENT_LOG_RETENTION_DAYS |
ExternalAnalyticsEvent (raw tracker events) |
7 days | Existing cleanup |
ExternalAnalyticsDaily (tracker aggregates) |
Indefinite (depersonalized) | Not configurable |
| Apache access logs | 14 days (logrotate) | /etc/logrotate.d/apache2-doable-server |
| Graylog | Per Graylog retention config | Graylog admin (configure separately) |
Aggregates are JSON counters like {"Chrome": 1043, "Firefox": 198} — they contain no per-user data and are safe to retain indefinitely for trend analysis.
For site operators: controller/processor relationship
Under GDPR Art 4(7-8), when you (the site operator) use doable-tracker to collect analytics about your visitors, you are the data controller and doable.services is the data processor acting on your instructions.
Data Processing Agreement (DPA) required
GDPR Art 28 requires a written DPA between controller and processor. The DPA must specify:
- Subject matter, duration, nature, and purpose of processing
- Types of personal data and categories of data subjects
- Obligations and rights of the controller
- Processor commitments (confidentiality, security, sub-processors, etc.)
- Assistance with data subject requests, breach notification, audits
- Return/deletion of data at end of contract
A template DPA is available at docs/dpa-template.md (forthcoming — contact your account). Customize with:
- Your organization's legal entity details
- The tracking project IDs covered
- Retention periods matching your privacy policy
Sign it, keep a copy, and reference the signed DPA in your privacy policy.
Data transfer (international)
doable.services infrastructure is hosted in Sweden (EU). Visitor data is: - Collected in the EU - Processed in the EU - Stored in the EU
This means no international transfer under GDPR Art 44-49 for EU/EEA-based site operators and visitors. No Standard Contractual Clauses (SCCs) required.
If doable.services ever moves infrastructure outside the EU, site operators will be notified in advance and SCCs will be added to the DPA. Current status is always documented in config.py comments and at the top of docs/gdpr-compliance.md.
For site operators outside the EU: your local data protection law may still require equivalent agreements. Verify with a DPO.
Visitor rights
Per GDPR Art 15-22, visitors can request:
| Right | How to handle |
|---|---|
| Access | Aggregates contain no identifiable data. Raw events are retained 7 days and can be looked up by session_id or client_ip_hash if the visitor provides these. |
| Rectification | Not applicable — we store no user-editable profile data. |
| Erasure | Send session_id or IP hash → delete matching rows from ExternalAnalyticsEvent. Aggregates don't contain per-user data. |
| Restriction | Visitor sets dt_consent=denied or uses doable('revoke') — future tracking stops. |
| Portability | Not applicable — no portable dataset per user. |
| Object | Same as restriction. |
Site operators using doable-tracker should include this in their privacy policy.
Configuring for GDPR compliance (checklist)
Tracker-only sites
- [ ] Admin toggles "Require cookie consent" on tracking project
- [ ] (Optional) Custom
consent_textin local language - [ ] (Optional)
privacy_urllinking to your privacy policy - [ ] Verify the always-visible floating revoke icon is enabled (default), OR provide your own revoke UI and set
hideRevokeButton: true - [ ] Update site privacy policy to mention doable-tracker, data collected, retention, visitor rights
- [ ] Sign DPA with doable.services (Art 28 requirement)
- [ ] Review data transfer section — verify doable.services' hosting region matches your visitors' jurisdiction
Proxy-routed sites (in addition to tracker-only)
- [ ] Admin toggles "Require cookie consent at proxy level" on the route
- [ ] Verify
PROXY_IP_TRUNCATE=truein server config - [ ] Deploy
/etc/logrotate.d/apache2-doable-serverwith 14-day rotation (mandatory) - [ ] Tune
ACCESS_LOG_RETENTION_DAYSto your policy (default 90) - [ ] Tune
CONSENT_LOG_RETENTION_DAYSto match legal statute of limitations (default 2555 / 7y) - [ ] Confirm retention cleanup cron is running (
pm2 listshould show access-log-cleanup) - [ ] Configure Graylog retention separately in Graylog admin
- [ ] Document legitimate-interest basis for minimal access logs in your privacy policy
- [ ] Disclose the first-request blind spot in your privacy policy if strict completeness is important
AI Insights privacy note
Doable AI Insights analyses each resource's analytics summary once a day using a local Ollama instance. Specifically:
- Input to the model is the already-aggregated summary from
/api/v1/summary/<id>(KPI deltas, top paths, top referrers, audience breakdowns). No per-visitor rows, no IPs, no user agents are sent to the model. - Ollama runs on the doable-server host. Data never leaves the server. There are no outbound API calls to OpenAI, Anthropic, Google, or any other cloud LLM.
- Insights are stored in
ai_insight_runswith a hash of the summary input so unchanged data is skipped within 23 h. - Retention:
AI_INSIGHTS_RETENTION_DAYS(default 365). - Email alerts (when a deterministic significant finding triggers) contain only the finding text plus a dashboard link — no raw analytics data.
- Per-user opt-out at
/dashboard/settingsdisables the email alerts for that user. The server-wide switch isAI_INSIGHTS_ENABLED.
Nothing about the AI Insights pipeline expands the personal-data surface established by cookie consent and proxy data minimization — it is a downstream consumer of the same already-minimized aggregate data.
Google Search Console privacy note
The GSC integration uses a shared service account authenticated to properties that users explicitly add the service-account email to as a Restricted user in Google Search Console. Specifically:
- Data returned by GSC is aggregate — queries with counts, landing pages with counts, device buckets, country buckets. GSC itself does not return individual-visitor information to any of its API consumers.
- Queries that matched fewer than GSC's own threshold are already filtered by Google for privacy before data reaches doable-server.
- The service account only accesses GSC properties for which it has been
explicitly granted Restricted-user access. Removing the email in GSC
revokes access immediately; after 3 consecutive failed sync attempts the
integration flips to
errorand an owner email is sent with re-add instructions. - GSC data is stored in
gsc_daily_snapshots,gsc_query_daily,gsc_page_daily,gsc_device_daily,gsc_country_daily. Retention:GSC_RETENTION_DAYS(default 730). - When an integration is disconnected, historical rows are retained for
GSC_POST_DISCONNECT_RETENTION_DAYS(default 30) before cascade-purge. - Service account credentials live in
instance/gsc-service-account.json(chmod 600, gitignored). The service account email is public information by design — it's the copy-paste identifier users add to their properties.
Version history of privacy features
| Version | Change |
|---|---|
| v2.2.1 | External tracking API introduced. IP hashing in ExternalAnalyticsEvent from start. |
| v2.6.21 | Human-only audience breakdowns (bot filtering reduces apparent data collection). |
| v2.6.23 | Cookie consent for tracker: dt_consent cookie, floating revoke icon, ConsentLog audit trail, lazy session ID, explicit Accept overrides DNT. Full GDPR coverage for tracker-only mode. |
| v2.7.0 | Proxy IP truncation (pseudonymization), consent-aware aggregation, DNT as fallback at proxy, retention cleanup (90d access logs, 7y consent logs), Apache logrotate mandate. Full GDPR coverage for proxy-routed mode. |
| v2.8.0 | Doable AI Insights pipeline runs on local Ollama; no data leaves the server. Email alerts contain finding text + dashboard link only. Per-user opt-out. |
| v2.9.0 | Google Search Console integration. Aggregate-only data, service-account scope limited to user-granted properties, cascade-purge after disconnect. |
| v2.9.1 | AI Insights and GSC privacy notes consolidated into this document. No behavioral change. |
Disclaimer
This document describes the technical capabilities of doable-server and doable-tracker. It does not constitute legal advice. GDPR compliance depends on your specific deployment, jurisdiction, the nature of data you collect beyond what doable tracks, and your documented lawful basis. Consult a qualified data protection officer or lawyer for your use case.