Skip to content
Start free in Cloud

Self-Host HitKeep with Docker Compose

Docker Compose gives you a reproducible, version-controlled deployment with persistent storage volumes — your analytics data stays in a named Docker volume on your server, including the shared hitkeep.db and any tenant-local databases created under tenants/*/hitkeep.db.

This page is for running HitKeep as a self-hosted service. If you want the hot-reload contributor environment with Go, Air, Angular, Mailpit, and seeded demo data in Docker, use the Contributing guide.

HitKeep images are published to two registries on every release:

Registry Image
Docker Hub pascalebeier/hitkeep
GitHub Container Registry ghcr.io/pascalebeier/hitkeep

Both registries carry identical, multi-platform images (linux/amd64, linux/arm64) with signed provenance attestations. Use whichever registry suits your network or pull-rate requirements.

A deployment is two files in one directory: compose.yml (copy it verbatim — you never edit it) and .env (your values live here). Compose substitutes every ${VARIABLE} in compose.yml with the matching line from .env at startup.

Create a directory and add this compose.yml. This baseline keeps the live data, retention archive, and automatic backup snapshots in separate named volumes. The same file is maintained in the repository as examples/compose.yml (with a matching examples/.env.example) if you prefer to copy from source.

services:
hitkeep:
image: pascalebeier/hitkeep:latest
container_name: hitkeep
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- hitkeep_data:/var/lib/hitkeep/data
- hitkeep_archive:/var/lib/hitkeep/archive
- hitkeep_backups:/var/lib/hitkeep/backups
environment:
# Public URL must match the browser-visible origin, including any path prefix.
HITKEEP_PUBLIC_URL: ${HITKEEP_PUBLIC_URL:-http://localhost:8080}
# Required for stable sessions. Generate with: openssl rand -hex 32
HITKEEP_JWT_SECRET: ${HITKEEP_JWT_SECRET:?set HITKEEP_JWT_SECRET in .env}
# Behind a reverse proxy, set to the proxy network CIDR so real client IPs are used.
HITKEEP_TRUSTED_PROXIES: ${HITKEEP_TRUSTED_PROXIES:-}
# Keep live data, retention archives, and backup snapshots on persistent volumes.
HITKEEP_DB_PATH: /var/lib/hitkeep/data/hitkeep.db
HITKEEP_DATA_PATH: /var/lib/hitkeep/data
HITKEEP_ARCHIVE_PATH: /var/lib/hitkeep/archive
HITKEEP_BACKUP_PATH: /var/lib/hitkeep/backups
HITKEEP_BACKUP_INTERVAL: ${HITKEEP_BACKUP_INTERVAL:-60}
HITKEEP_BACKUP_RETENTION: ${HITKEEP_BACKUP_RETENTION:-24}
# Optional OSS spam-list refresh. Disable for fully offline/air-gapped installs.
HITKEEP_SPAM_FILTER_AUTO_UPDATE: "true"
HITKEEP_SPAM_FILTER_UPDATE_INTERVAL: ${HITKEEP_SPAM_FILTER_UPDATE_INTERVAL:-1440}
HITKEEP_SPAM_FILTER_PATH: /var/lib/hitkeep/data/spam-filter.json
# Optional read-only MCP endpoint for governed assistant/reporting access.
HITKEEP_MCP_ENABLED: "true"
HITKEEP_MCP_PATH: /mcp
HITKEEP_MCP_MAX_RANGE_DAYS: ${HITKEEP_MCP_MAX_RANGE_DAYS:-366}
# Optional AI model route for Opportunity enrichment. Provider credentials
# use the selected goAI provider's own env vars, such as OPENAI_API_KEY.
HITKEEP_AI_ENABLED: ${HITKEEP_AI_ENABLED:-false}
HITKEEP_AI_PROVIDER: ${HITKEEP_AI_PROVIDER:-}
HITKEEP_AI_MODEL: ${HITKEEP_AI_MODEL:-}
HITKEEP_AI_BASE_URL: ${HITKEEP_AI_BASE_URL:-}
HITKEEP_AI_REGION: ${HITKEEP_AI_REGION:-}
HITKEEP_AI_API_KEY: ${HITKEEP_AI_API_KEY:-}
HITKEEP_AI_REQUEST_LIMIT: ${HITKEEP_AI_REQUEST_LIMIT:-100}
HITKEEP_AI_TOKEN_LIMIT: ${HITKEEP_AI_TOKEN_LIMIT:-100000}
HITKEEP_AI_BUDGET_WINDOW: ${HITKEEP_AI_BUDGET_WINDOW:-1440}
# Optional Google Search Console OAuth integration.
HITKEEP_GOOGLE_SEARCH_CONSOLE_CLIENT_ID: ${HITKEEP_GOOGLE_SEARCH_CONSOLE_CLIENT_ID:-}
HITKEEP_GOOGLE_SEARCH_CONSOLE_CLIENT_SECRET: ${HITKEEP_GOOGLE_SEARCH_CONSOLE_CLIENT_SECRET:-}
HITKEEP_GOOGLE_SEARCH_CONSOLE_REDIRECT_URL: ${HITKEEP_GOOGLE_SEARCH_CONSOLE_REDIRECT_URL:-}
# SMTP powers invites, password reset, email reports, and security mail.
HITKEEP_MAIL_HOST: ${HITKEEP_MAIL_HOST:-}
HITKEEP_MAIL_PORT: ${HITKEEP_MAIL_PORT:-587}
HITKEEP_MAIL_USERNAME: ${HITKEEP_MAIL_USERNAME:-}
HITKEEP_MAIL_PASSWORD: ${HITKEEP_MAIL_PASSWORD:-}
HITKEEP_MAIL_ENCRYPTION: ${HITKEEP_MAIL_ENCRYPTION:-tls}
HITKEEP_MAIL_FROM_ADDRESS: ${HITKEEP_MAIL_FROM_ADDRESS:-hitkeep@localhost}
HITKEEP_MAIL_FROM_NAME: ${HITKEEP_MAIL_FROM_NAME:-HitKeep}
volumes:
hitkeep_data: {}
hitkeep_archive: {}
hitkeep_backups: {}

Generate the session signing key first — run this in your terminal and copy the output:

Terminal window
openssl rand -hex 32

If openssl is not installed, head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'; echo produces an equivalent value on any Linux box.

Then create a file named .env next to compose.yml with your editor and fill in your values:

# Required. Paste the generated 64-character hex string here.
HITKEEP_JWT_SECRET=paste-the-generated-value-here
# Required. The exact URL you open in the browser. Behind a reverse proxy
# this is your public domain, e.g. https://analytics.example.com
HITKEEP_PUBLIC_URL=http://localhost:8080
# Optional SMTP. Powers invites, password resets, and email reports.
# Leave the values empty to run without outbound mail.
HITKEEP_MAIL_HOST=
HITKEEP_MAIL_PORT=587
HITKEEP_MAIL_USERNAME=
HITKEEP_MAIL_PASSWORD=
HITKEEP_MAIL_FROM_ADDRESS=hitkeep@localhost
HITKEEP_MAIL_FROM_NAME=HitKeep
Terminal window
docker compose up -d
docker compose logs -f hitkeep

The log lines Starting HitKeep and HTTP server starting mean the service is up.

Open HITKEEP_PUBLIC_URL in your browser. The setup wizard creates the first account, which automatically becomes the instance owner, and walks you through adding your first site.

The database file lives inside the hitkeep_data volume. The default automatic-recovery directory, /var/lib/hitkeep/data/recovery, is therefore persistent too. Retention archives live in hitkeep_archive, and automatic database snapshots are written to hitkeep_backups. Recovery bundles contain database material, are not rotated with backup retention, and must be protected and expired separately. MCP is exposed at /mcp, but clients still need scoped API client bearer tokens.

MCP is optional in the product and is enabled in this compose example for teams that want read-only assistant/reporting access. Remove HITKEEP_MCP_ENABLED, HITKEEP_MCP_PATH, and HITKEEP_MCP_MAX_RANGE_DAYS if you do not plan to publish an MCP endpoint.

AI provider enrichment is optional and disabled by default. Keep HITKEEP_AI_ENABLED=false until you have chosen a provider/model, configured credentials, and set local budget caps. See Opportunity Recommendations and the AI model configuration guide for the exact fields.

City, provider, and ASN lookup data is embedded in release images. Runtime Compose deployments do not need IP2LOCATION_DOWNLOAD_TOKEN.

Run HitKeep behind a reverse proxy for production HTTPS. Configure Trusted Proxies so real client IPs are used for analytics and rate limiting.

HITKEEP_PUBLIC_URL may include a path prefix. For example, set HITKEEP_PUBLIC_URL=https://www.example.net/hitkeep/ when HitKeep is mounted below an existing site. The proxy should publish the same prefix, such as /hitkeep/*, and forward it to the HitKeep container. HitKeep then serves the dashboard base href, API requests, hk.js, hk-vitals.js, and ingest endpoints below that prefix.

caddy-docker-proxy handles automatic HTTPS (Let’s Encrypt) and generates Caddy config directly from Docker labels. Best practice is to use a dedicated ingress network and trust only that network CIDR in HitKeep.

services:
caddy:
image: lucaslorentz/caddy-docker-proxy:2.9-alpine
container_name: caddy-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
CADDY_INGRESS_NETWORKS: caddy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- caddy_data:/data
- caddy_config:/config
networks:
- caddy
labels:
caddy.email: ${CADDY_EMAIL:?set in .env}
hitkeep:
image: pascalebeier/hitkeep:latest
container_name: hitkeep
restart: unless-stopped
networks:
- caddy
volumes:
- hitkeep_data:/var/lib/hitkeep/data
- hitkeep_archive:/var/lib/hitkeep/archive
- hitkeep_backups:/var/lib/hitkeep/backups
environment:
HITKEEP_NODE_NAME: leader
# Public URL must match the HTTPS origin served by Caddy, including any path prefix.
HITKEEP_PUBLIC_URL: ${HITKEEP_PUBLIC_URL:?set in .env}
# Required for stable sessions. Generate with: openssl rand -hex 32
HITKEEP_JWT_SECRET: ${HITKEEP_JWT_SECRET:?set in .env}
# Must be the Caddy network CIDR. This controls real visitor IP, geo/network metadata,
# country exclusions, rate limiting, and spam checks.
HITKEEP_TRUSTED_PROXIES: ${HITKEEP_TRUSTED_PROXIES:?set to your caddy network CIDR}
# Keep live data, retention archives, and backup snapshots on persistent volumes.
HITKEEP_DB_PATH: /var/lib/hitkeep/data/hitkeep.db
HITKEEP_DATA_PATH: /var/lib/hitkeep/data
HITKEEP_ARCHIVE_PATH: /var/lib/hitkeep/archive
HITKEEP_BACKUP_PATH: /var/lib/hitkeep/backups
HITKEEP_BACKUP_INTERVAL: ${HITKEEP_BACKUP_INTERVAL:-60}
HITKEEP_BACKUP_RETENTION: ${HITKEEP_BACKUP_RETENTION:-24}
# Optional OSS spam-list refresh. Disable for fully offline/air-gapped installs.
HITKEEP_SPAM_FILTER_AUTO_UPDATE: "true"
HITKEEP_SPAM_FILTER_UPDATE_INTERVAL: ${HITKEEP_SPAM_FILTER_UPDATE_INTERVAL:-1440}
HITKEEP_SPAM_FILTER_PATH: /var/lib/hitkeep/data/spam-filter.json
# Optional read-only MCP endpoint for governed assistant/reporting access.
HITKEEP_MCP_ENABLED: "true"
HITKEEP_MCP_PATH: /mcp
HITKEEP_MCP_MAX_RANGE_DAYS: ${HITKEEP_MCP_MAX_RANGE_DAYS:-366}
# Optional AI model route for Opportunity enrichment. Leave disabled until a provider is configured.
HITKEEP_AI_ENABLED: ${HITKEEP_AI_ENABLED:-false}
HITKEEP_AI_PROVIDER: ${HITKEEP_AI_PROVIDER:-}
HITKEEP_AI_MODEL: ${HITKEEP_AI_MODEL:-}
HITKEEP_AI_BASE_URL: ${HITKEEP_AI_BASE_URL:-}
HITKEEP_AI_REGION: ${HITKEEP_AI_REGION:-}
HITKEEP_AI_API_KEY: ${HITKEEP_AI_API_KEY:-}
HITKEEP_AI_REQUEST_LIMIT: ${HITKEEP_AI_REQUEST_LIMIT:-100}
HITKEEP_AI_TOKEN_LIMIT: ${HITKEEP_AI_TOKEN_LIMIT:-100000}
HITKEEP_AI_BUDGET_WINDOW: ${HITKEEP_AI_BUDGET_WINDOW:-1440}
# Optional Google Search Console OAuth integration.
HITKEEP_GOOGLE_SEARCH_CONSOLE_CLIENT_ID: ${HITKEEP_GOOGLE_SEARCH_CONSOLE_CLIENT_ID:-}
HITKEEP_GOOGLE_SEARCH_CONSOLE_CLIENT_SECRET: ${HITKEEP_GOOGLE_SEARCH_CONSOLE_CLIENT_SECRET:-}
HITKEEP_GOOGLE_SEARCH_CONSOLE_REDIRECT_URL: ${HITKEEP_GOOGLE_SEARCH_CONSOLE_REDIRECT_URL:-}
# SMTP powers invites, password reset, email reports, and security mail.
HITKEEP_MAIL_HOST: ${HITKEEP_MAIL_HOST:-}
HITKEEP_MAIL_PORT: ${HITKEEP_MAIL_PORT:-587}
HITKEEP_MAIL_USERNAME: ${HITKEEP_MAIL_USERNAME:-}
HITKEEP_MAIL_PASSWORD: ${HITKEEP_MAIL_PASSWORD:-}
HITKEEP_MAIL_ENCRYPTION: ${HITKEEP_MAIL_ENCRYPTION:-tls}
HITKEEP_MAIL_FROM_ADDRESS: ${HITKEEP_MAIL_FROM_ADDRESS:-hitkeep@localhost}
HITKEEP_MAIL_FROM_NAME: ${HITKEEP_MAIL_FROM_NAME:-HitKeep}
labels:
caddy: ${HITKEEP_HOSTNAME:?set in .env}
caddy.reverse_proxy: "{{upstreams 8080}}"
caddy.encode: "zstd gzip"
volumes:
caddy_data: {}
caddy_config: {}
hitkeep_data: {}
hitkeep_archive: {}
hitkeep_backups: {}
networks:
caddy:
external: true

Two values come from your terminal — the session secret and the Caddy network CIDR:

Terminal window
openssl rand -hex 32
docker network inspect caddy --format '{{(index .IPAM.Config 0).Subnet}}'

Then create the .env file with your editor:

# Required. Paste the generated 64-character hex string here.
HITKEEP_JWT_SECRET=paste-the-generated-value-here
HITKEEP_PUBLIC_URL=https://analytics.example.com
HITKEEP_HOSTNAME=analytics.example.com
CADDY_EMAIL=ops@example.com
# Paste the Caddy network subnet from the docker network inspect command.
HITKEEP_TRUSTED_PROXIES=paste-the-caddy-subnet-here
HITKEEP_MAIL_HOST=smtp.example.com
HITKEEP_MAIL_PORT=587
HITKEEP_MAIL_USERNAME=postmaster@example.com
HITKEEP_MAIL_PASSWORD=change-me
HITKEEP_MAIL_FROM_ADDRESS=analytics@example.com
HITKEEP_MAIL_FROM_NAME=HitKeep

HITKEEP_TRUSTED_PROXIES must be the CIDR of your reverse-proxy network (not 0.0.0.0/0).

For a subdirectory mount, keep HITKEEP_PUBLIC_URL and the reverse-proxy route prefix aligned. The equivalent Caddy route is:

Terminal window
HITKEEP_PUBLIC_URL=https://www.example.net/hitkeep/
HITKEEP_HOSTNAME=www.example.net
www.example.net {
handle /hitkeep* {
reverse_proxy hitkeep:8080
}
encode zstd gzip
}

Self-hosted teams can use Custom Tracking Domains with any reverse proxy that terminates TLS and preserves the original Host header. Use external TLS mode when certificates are managed by nginx, Traefik, a load balancer, or another ACME process. Use the Caddy profile only when you want dashboard-driven domain onboarding with on-demand certificate issuance.

Add these HitKeep settings when the reverse proxy already handles certificates:

HITKEEP_CUSTOM_TRACKING_TLS_MODE: external

In external mode, add each tracking hostname to the proxy before the team clicks Verify in HitKeep. The verification probe requires https://<hostname>/hk.js to return the tracker with a valid certificate.

The HitKeep repository includes non-Caddy examples:

The nginx example restricts custom tracking hosts to tracker assets and ingest routes at the proxy. The Traefik example uses exact Host(...) entries for every tracking hostname; do not replace that with a catch-all rule unless you intentionally want the proxy to accept arbitrary hostnames.

Add these HitKeep settings to the hitkeep service:

HITKEEP_CUSTOM_TRACKING_TLS_MODE: caddy-on-demand
HITKEEP_CADDY_TLS_ASK_TOKEN: ${HITKEEP_CADDY_TLS_ASK_TOKEN:?set in .env}
# Optional. Use only when tracker hostnames point somewhere other than
# the host in HITKEEP_PUBLIC_URL.
HITKEEP_CUSTOM_TRACKING_DNS_TARGET: ${HITKEEP_CUSTOM_TRACKING_DNS_TARGET:-}

Generate the ask token in your terminal (openssl rand -hex 32) and add it as a new line in .env:

HITKEEP_CADDY_TLS_ASK_TOKEN=paste-the-generated-value-here

Use a Caddyfile with persistent storage and a restricted on-demand TLS ask endpoint:

{
email ops@example.com
storage file_system {
root /data
}
on_demand_tls {
ask http://hitkeep:8080/internal/caddy/on-demand-tls/{$HITKEEP_CADDY_TLS_ASK_TOKEN}
}
}
{$HITKEEP_HOSTNAME} {
reverse_proxy hitkeep:8080
encode zstd gzip
}
https:// {
tls {
on_demand
}
reverse_proxy hitkeep:8080 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
}
encode zstd gzip
}

Caddy sends ?domain= to the ask URL before issuing a certificate. HitKeep returns 204 only when the token matches and the requested hostname is an enabled DNS-verified custom tracking domain. Do not run Caddy on-demand TLS without ask; otherwise any hostname that reaches your Caddy listener can attempt certificate issuance.

The HitKeep repository includes a complete optional profile:

The examples above use local Docker volumes. To write backup snapshots or retention archives to object storage, replace the local paths with s3:// URLs and add the S3 settings to .env:

Terminal window
# Local or S3 backup snapshots. Empty HITKEEP_BACKUP_PATH disables automatic backups.
HITKEEP_BACKUP_PATH=s3://my-analytics-bucket/hitkeep/backups
# Retention archives can use local paths or S3-compatible URLs.
HITKEEP_ARCHIVE_PATH=s3://my-analytics-bucket/hitkeep/archive
HITKEEP_S3_REGION=eu-central-1
# Static credentials are optional when your container runtime provides an AWS credential chain.
HITKEEP_S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
HITKEEP_S3_SECRET_ACCESS_KEY=change-me

For MinIO, Cloudflare R2, Backblaze B2, or another S3-compatible endpoint, also set HITKEEP_S3_ENDPOINT and, when needed, HITKEEP_S3_URL_STYLE=path. See S3 Backups for provider-specific examples.

The dashboard shows “An unexpected error occurred” during setup or sign-in. Work through these in order:

  1. Read the container logs first: docker compose logs hitkeep. HitKeep logs every server-side failure at error level. If the browser shows an error but the logs stay silent, the request never reached the container — that points at the reverse proxy or a wrong HITKEEP_PUBLIC_URL, not at HitKeep.
  2. HITKEEP_PUBLIC_URL must exactly match the address bar — scheme, host, and port. With an https:// public URL, session cookies are marked secure and sign-in over plain HTTP silently fails. After changing .env, run docker compose up -d again; Compose only applies environment changes when it recreates the container.
  3. Setup has already been completed. means a user already exists in the database — typically a hitkeep_data volume left over from an earlier attempt. docker compose down -v removes the named volumes and gives you a clean start (this deletes all analytics data).
  4. Mail settings are not the culprit during setup. Creating the first account sends no email; empty HITKEEP_MAIL_* values never block it. SMTP only matters later for invites, password resets, and email reports.
  5. Need more detail? Add HITKEEP_LOG_LEVEL: debug under environment: in compose.yml and run docker compose up -d again.

Need managed hosting with explicit region choice? HitKeep Cloud → runs HitKeep in your chosen managed region: EU (Frankfurt) or US (Virginia).