Vibecode HitKeep Cloud
track this build4 phases, 12 steps, beginner friendly0%The subscription is managed hosting of the same MIT-licensed application, so an agent does not need to reproduce HitKeep. It can deploy the official binary or container in one sitting; the real gap is upgrades, backups, email delivery, monitoring, and being on call for the analytics box.
You are building a lean indie version of HitKeep Cloud.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# HitKeep Cloud · indie build
A single-container deployment of an MIT-licensed analytics server, where the work is not the application but the proxy configuration, the off-box backup, the tested restore, the pinned upgrade and knowing within a day that collection has stopped.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Application | The published pascalebeier/hitkeep image, unmodified, pinned by digest | modifying it means you own the merge conflicts forever |
| Storage | Named volumes for the data, archive and backup paths the image expects | the container is disposable and the volumes are not |
| Database | Whatever the image embeds, left alone | the release notes say when a format changes, and no external database is needed or wanted |
| Network | Bound to localhost with your existing reverse proxy terminating TLS | one place that holds certificates is better than two |
| Backups | The built-in snapshots plus a copy on another machine, restore-tested | a snapshot beside the database survives a mistake but not a dead disk |
| Upgrades | Pinned digest, snapshot first, release notes read, rollback rehearsed | an unattended pull of a moving tag is how an analytics box goes quiet |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Docker Engine with the Compose plugin** · free
- Why: Runs the published image and manages the named volumes.
- Get it: Install from your distribution's repository or docker.com.
- Verify: docker compose version prints a version
- [ ] **A small always-on Linux host** · a few pounds a month
- Why: Analytics has to collect while nobody is watching, which is exactly the property a laptop does not have.
- Get it: Any small virtual machine, or a home server with a static power supply.
- Verify: The host survives a reboot with the service coming back automatically
- [ ] **A hostname and a reverse proxy that terminates TLS** · the price of a domain
- Why: The public URL value has to match the address people use exactly, and the tracker script is loaded from it.
- Get it: A subdomain pointed at the host, served by the proxy you already run.
- Verify: The hostname resolves and serves a valid certificate
- [ ] **A second destination for backups** · pennies per month
- Why: The container's own snapshots sit on the same disk as the database, which covers a mistake but not a dead machine.
- Get it: Object storage, another server, or a drive that is not in the same box.
- Verify: A snapshot copied there has a matching checksum and has been restored once
- [ ] **Retention, alert thresholds and who is on call** · free
- Why: These are the subscription you are replacing, and leaving them undecided is how a self-hosted analytics box quietly stops collecting.
- Get it: Write down how long data is kept, at what disk level you want warning, and whose phone rings.
- Verify: All three appear in the runbook and in the alert configuration
## Quick start
```sh
docker image inspect --format '{{index .RepoDigests 0}}' pascalebeier/hitkeep:latest
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Any reimplementation of the application.
- Modifications to the published image.
- An external database, queue or cache.
- Accounts, billing or an uptime promise.
- managed upgrades and monitoring
- encrypted regional backups
- managed email delivery
- vendor support and incident response
If one of those is essential to you, that is the reason to keep paying for HitKeep Cloud, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · HitKeep Cloud
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a self-hosted deployment of the open-source analytics server that the subscription hosts for you to replace HitKeep Cloud. Build it in phases, in the order below.
Do not write the whole thing in one pass. Finish a phase, run its "Done when"
check, fix what fails, and only then start the next phase.
### Rule zero
Do not write an analytics application. HitKeep is MIT licensed and published as a container
image, so the product you are replacing is the hosting, not the software. Every hour spent
reimplementing the dashboard is an hour not spent on the thing you are actually taking on.
What you are taking on is operations, and the honest version of that starts with a restore
rather than ending with one. The image writes its own snapshots on an interval, with a
retention window whose default is stated in hours, not weeks (checked against the published
compose guide), and those snapshots sit in a volume on the same machine as the database they
protect. A restore you have never performed is a belief. Copy a snapshot off the box and bring
it back up on a scratch stack before you put the tracker tag on anything that matters.
### Stack (fixed, do not substitute)
- Application: The published pascalebeier/hitkeep image, unmodified, pinned by digest · modifying it means you own the merge conflicts forever
- Storage: Named volumes for the data, archive and backup paths the image expects · the container is disposable and the volumes are not
- Database: Whatever the image embeds, left alone · the release notes say when a format changes, and no external database is needed or wanted
- Network: Bound to localhost with your existing reverse proxy terminating TLS · one place that holds certificates is better than two
- Backups: The built-in snapshots plus a copy on another machine, restore-tested · a snapshot beside the database survives a mistake but not a dead disk
- Upgrades: Pinned digest, snapshot first, release notes read, rollback rehearsed · an unattended pull of a moving tag is how an analytics box goes quiet
### Data model (create this before Phase 1)
- compose.yml · one service, restart unless-stopped, the image pinned by digest rather than by a moving tag
- volume hitkeep_data mounted at the data path · the live database and the counts you care about
- volume hitkeep_archive mounted at the archive path · retention archives, which grow and need a ceiling
- volume hitkeep_backups mounted at the backup path · the automatic snapshots, on the same disk as the data they protect
- .env holding the public URL and the session signing secret, with a committed .env.example carrying placeholders only
- backup-manifest · for each off-box copy: file, size, checksum, the snapshot time, and the date it was last restore-tested
- runbook.md · the upgrade, the rollback and the restore, written before you need any of them
### Phase 1 · The published image, running and reachable
Build: Write compose.yml for one service from the published image, pinned by digest. Mount named
volumes at the data, archive and backup paths the image documents. Generate the session
signing secret locally and put it in .env beside the public URL, with a placeholder-only
.env.example committed. Bind the port to localhost. Use the documented liveness and readiness
endpoints in the health check, preferring readiness, which reports on the databases rather
than just the process.
Trap: Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
Done when: The container reports healthy against the readiness endpoint, not merely the liveness one. Stopping and recreating the container loses nothing, tested by recreating it and reloading the dashboard. The signing secret exists only in .env and in your password manager, verified by grepping the repository. The service is not reachable on the host's public address, verified from another machine.
Do not build yet: No TLS, no public domain, no tracker tag on a real site.
### Phase 2 · A public URL and honest client addresses
Build: Point a hostname at the box and terminate TLS in the reverse proxy you already run, passing
the forwarded headers through. Set the public URL variable to exactly the address people
type, scheme and all. Set the trusted proxy variable to the proxy's network range so the
real client address is used, and to nothing wider than that.
Trap: Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
Done when: A visit from another network is recorded with that network's address and not the proxy's, verified in the dashboard. A forged forwarded header from an untrusted address does not change the recorded client, tested with curl. The public URL value matches the address in the browser exactly, including scheme, and login sessions survive a reload. The certificate renews automatically and the renewal has been exercised once.
Do not build yet: No backups off the box, no upgrades, no alerting.
### Phase 3 · A backup you have actually restored
Build: Set the snapshot interval and the retention window deliberately rather than accepting the
defaults, noting that the documented retention default is expressed in hours (checked), so
the built-in history is roughly a day deep. Write a script that copies the newest snapshot to
a second destination with a checksum and records it in a manifest. Then restore it onto a
scratch stack on a different path and confirm the dashboard shows the same numbers.
Trap: The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
Done when: A snapshot from the box exists on a second machine with a matching checksum, recorded in the manifest. A scratch stack started from that copy shows the same site list and the same visitor totals as production, performed rather than described. The off-box retention is longer than the container's own and both figures are written in the runbook. Deleting the local backup volume does not lose the off-box generations, tested deliberately.
Do not build yet: No upgrade procedure and no alerting yet.
### Phase 4 · Upgrades you can undo
Build: Write an update script that reads the release notes, takes a snapshot, records the current
digest, pulls the new one, recreates the service and watches the readiness endpoint and the
logs. Write the rollback beside it: stop, restore the pre-upgrade snapshot, pin the recorded
digest, start. Rehearse the rollback once on the scratch stack.
Trap: Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
Done when: The update script refuses to proceed if the pre-upgrade snapshot fails, tested by making it fail. The recorded previous digest is written somewhere outside the container before the pull. A rollback has been performed end to end on the scratch stack and the runbook matches what actually happened. No scheduled job pulls images on this host, verified.
Do not build yet: No alerting or export yet.
### Phase 5 · Knowing it is still collecting, and being able to leave
Build: Add an external check that fires a synthetic event and then asserts it appears, rather than
only asking whether the page returns. Alert on collection stopping, on disk crossing a
threshold, and on the readiness endpoint failing. Watch the archive volume's growth and set a
retention you have decided. Export the data on a schedule in a format that opens without this
application, and write down what you would do if the project stopped being maintained.
Trap: Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want.
Done when: A synthetic event is sent and confirmed present on a schedule, and breaking the tracker tag deliberately raises the alert within the stated window. Disk usage for each of the three volumes is graphed or logged, with an alert threshold below the volume size. A scheduled export produces a file that opens in a spreadsheet or a database without this application. The runbook names who is on call, which is a real answer even when it is one person.
Do not build yet: Nothing after this.
### Out of scope (and why)
- Writing an analytics application, since the one being hosted is open source and reimplementing it replaces a solved problem with an unsolved one.
- Modifying the image, which converts every future release into a merge and every bug report into your bug report.
- Adding an external database, queue or cache, none of which the image asks for and each of which is another thing to back up and upgrade.
- Multi-tenant accounts and billing around the deployment, which is the hosted business and not a self-hosted install.
- Promising anyone an uptime figure, since you are now the person who would have to honour it.
### README must contain
- That this is the published open-source application run unmodified, pinned by digest, and not a reimplementation
- Where every persistent file lives, which volume holds it, and which of them is the one that matters
- That the built-in snapshot retention is short and measured in hours, so the off-box copy is the real backup
- The restore procedure, and the date the restore was last actually performed
- That the upgrade takes a snapshot and records the outgoing digest first, with the rollback written beside it
- The operational responsibilities you have taken on: TLS, upgrades, backups, disk and being the person who notices
===== AGENTS.md =====
# Agent instructions · HitKeep Cloud indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: The published pascalebeier/hitkeep image, unmodified, pinned by digest, Named volumes for the data, archive and backup paths the image expects, Whatever the image embeds, left alone, Bound to localhost with your existing reverse proxy terminating TLS, The built-in snapshots plus a copy on another machine, restore-tested, Pinned digest, snapshot first, release notes read, rollback rehearsed. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
- Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
- The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
- Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
===== BUILD_PLAN.md =====
# Build plan · HitKeep Cloud
A single-container deployment of an MIT-licensed analytics server, where the work is not the application but the proxy configuration, the off-box backup, the tested restore, the pinned upgrade and knowing within a day that collection has stopped.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · The published image, running and reachable
The official container starts, reports ready, and keeps its state in volumes you named.
### Steps
1. Pin the image by digest, not by a tag
A tag moves, and it moves at the worst possible time.
Files: `compose.yml`
```sh
docker image inspect --format '{{index .RepoDigests 0}}' pascalebeier/hitkeep:latest
```
2. Name three volumes and mount them at the documented paths
Data, archive and backups are separate concerns with separate growth.
Files: `compose.yml`
3. Generate the secret locally and commit only a placeholder
A signing secret that reaches a git history has to be rotated, which logs everyone out.
Files: `.env.example`, `.gitignore`
```sh
openssl rand -hex 32
```
### Done when
- [ ] Readiness, not just liveness, reports healthy
- [ ] Recreating the container loses no data
- [ ] No secret appears anywhere in the repository
- [ ] The port is not reachable from outside the host
### Watch out
- Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
## Phase 2 · A public URL and honest client addresses
TLS terminates at your proxy and the counts reflect visitors rather than the proxy.
### Steps
1. Terminate TLS in the proxy you already operate
Two certificate stores is one more than anyone maintains.
Files: `proxy/hitkeep.conf`
2. Set the public URL to the exact address, scheme included
A mismatch here shows up as sessions that will not stick.
Files: `.env`
3. Trust the proxy range and nothing else
Then verify the recorded address from a machine outside your network.
Files: `compose.yml`
```sh
curl -H 'X-Forwarded-For: 1.2.3.4' https://analytics.example.com/
```
### Done when
- [ ] An outside visit records the outside address
- [ ] A forged forwarded header changes nothing
- [ ] Sessions survive a reload on the public URL
- [ ] Certificate renewal has been exercised
### Watch out
- Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
## Phase 3 · A backup you have actually restored
A snapshot exists on another machine and has been brought back up at least once.
### Steps
1. Choose the interval and retention on purpose
The defaults are for a demonstration, not for a year of history.
Files: `compose.yml`, `.env`
2. Copy off the box with a checksum and a manifest
A backup nobody has checksummed is a file of unknown provenance.
Files: `backup.sh`, `backups/manifest.csv`
3. Restore onto a scratch stack and compare totals
This is the step everyone documents and nobody performs.
Files: `restore.sh`, `runbook.md`
### Done when
- [ ] An off-box copy exists with a verified checksum
- [ ] A scratch restore shows matching totals
- [ ] Off-box retention exceeds the container's own
- [ ] Losing the local backup volume loses nothing
### Watch out
- The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
## Phase 4 · Upgrades you can undo
A new image can be tried and reversed without losing a day of data.
### Steps
1. Snapshot, then record the digest, then pull
In that order, because the third step is the only reversible one.
Files: `update.sh`
2. Write the rollback and rehearse it
A rollback first attempted during an outage is a second outage.
Files: `rollback.sh`, `runbook.md`
3. Read the release notes before every upgrade
A one-way migration is always mentioned, and always by someone nobody read.
Files: `runbook.md`
### Done when
- [ ] A failed pre-upgrade snapshot stops the update
- [ ] The previous digest is recorded outside the container
- [ ] A rollback has been performed on the scratch stack
- [ ] Nothing pulls images automatically on this host
### Watch out
- Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
## Not in this build
- Any reimplementation of the application.
- Modifications to the published image.
- An external database, queue or cache.
- Accounts, billing or an uptime promise.
## After v1, if you want it
- A second site on the same instance, once the restore drill has been run with real data in it
- Shipping the export into a warehouse, so long-term analysis never depends on this box being up
- A staging stack fed from a restored snapshot, which makes every upgrade a rehearsal first
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Exactly the address people open, scheme included. A mismatch breaks sessions.
HITKEEP_PUBLIC_URL=https://analytics.example.com
# Required · secret. Session signing. Generated on the host, stored in your password manager, never in git.
HITKEEP_JWT_SECRET=generated-locally-never-committed
# Required. The proxy's network range only. Wider means forged client addresses.
HITKEEP_TRUSTED_PROXIES=172.18.0.0/16
# Required. Snapshot interval in minutes. Set it on purpose rather than accepting the default.
HITKEEP_BACKUP_INTERVAL=60
# Required. Local retention in hours. Short by design, which is why the off-box copy exists.
HITKEEP_BACKUP_RETENTION=72
# Required. Pinned by digest. The update script changes this line and records the old one.
HITKEEP_IMAGE=pascalebeier/hitkeep@sha256:...redacted
# Required. Where the off-box copies land, with more generations than the container keeps.
BACKUP_REMOTE_TARGET=s3://backups.example.com/hitkeep/
You are building a lean indie version of HitKeep Cloud.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# HitKeep Cloud · indie build
A single-container deployment of an MIT-licensed analytics server, where the work is not the application but the proxy configuration, the off-box backup, the tested restore, the pinned upgrade and knowing within a day that collection has stopped.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Application | The published pascalebeier/hitkeep image, unmodified, pinned by digest | modifying it means you own the merge conflicts forever |
| Storage | Named volumes for the data, archive and backup paths the image expects | the container is disposable and the volumes are not |
| Database | Whatever the image embeds, left alone | the release notes say when a format changes, and no external database is needed or wanted |
| Network | Bound to localhost with your existing reverse proxy terminating TLS | one place that holds certificates is better than two |
| Backups | The built-in snapshots plus a copy on another machine, restore-tested | a snapshot beside the database survives a mistake but not a dead disk |
| Upgrades | Pinned digest, snapshot first, release notes read, rollback rehearsed | an unattended pull of a moving tag is how an analytics box goes quiet |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Docker Engine with the Compose plugin** · free
- Why: Runs the published image and manages the named volumes.
- Get it: Install from your distribution's repository or docker.com.
- Verify: docker compose version prints a version
- [ ] **A small always-on Linux host** · a few pounds a month
- Why: Analytics has to collect while nobody is watching, which is exactly the property a laptop does not have.
- Get it: Any small virtual machine, or a home server with a static power supply.
- Verify: The host survives a reboot with the service coming back automatically
- [ ] **A hostname and a reverse proxy that terminates TLS** · the price of a domain
- Why: The public URL value has to match the address people use exactly, and the tracker script is loaded from it.
- Get it: A subdomain pointed at the host, served by the proxy you already run.
- Verify: The hostname resolves and serves a valid certificate
- [ ] **A second destination for backups** · pennies per month
- Why: The container's own snapshots sit on the same disk as the database, which covers a mistake but not a dead machine.
- Get it: Object storage, another server, or a drive that is not in the same box.
- Verify: A snapshot copied there has a matching checksum and has been restored once
- [ ] **Retention, alert thresholds and who is on call** · free
- Why: These are the subscription you are replacing, and leaving them undecided is how a self-hosted analytics box quietly stops collecting.
- Get it: Write down how long data is kept, at what disk level you want warning, and whose phone rings.
- Verify: All three appear in the runbook and in the alert configuration
## Quick start
```sh
docker image inspect --format '{{index .RepoDigests 0}}' pascalebeier/hitkeep:latest
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Any reimplementation of the application.
- Modifications to the published image.
- An external database, queue or cache.
- Accounts, billing or an uptime promise.
- managed upgrades and monitoring
- encrypted regional backups
- managed email delivery
- vendor support and incident response
If one of those is essential to you, that is the reason to keep paying for HitKeep Cloud, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · HitKeep Cloud
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a self-hosted deployment of the open-source analytics server that the subscription hosts for you to replace HitKeep Cloud. Build it in phases, in the order below.
Do not write the whole thing in one pass. Finish a phase, run its "Done when"
check, fix what fails, and only then start the next phase.
### Rule zero
Do not write an analytics application. HitKeep is MIT licensed and published as a container
image, so the product you are replacing is the hosting, not the software. Every hour spent
reimplementing the dashboard is an hour not spent on the thing you are actually taking on.
What you are taking on is operations, and the honest version of that starts with a restore
rather than ending with one. The image writes its own snapshots on an interval, with a
retention window whose default is stated in hours, not weeks (checked against the published
compose guide), and those snapshots sit in a volume on the same machine as the database they
protect. A restore you have never performed is a belief. Copy a snapshot off the box and bring
it back up on a scratch stack before you put the tracker tag on anything that matters.
### Stack (fixed, do not substitute)
- Application: The published pascalebeier/hitkeep image, unmodified, pinned by digest · modifying it means you own the merge conflicts forever
- Storage: Named volumes for the data, archive and backup paths the image expects · the container is disposable and the volumes are not
- Database: Whatever the image embeds, left alone · the release notes say when a format changes, and no external database is needed or wanted
- Network: Bound to localhost with your existing reverse proxy terminating TLS · one place that holds certificates is better than two
- Backups: The built-in snapshots plus a copy on another machine, restore-tested · a snapshot beside the database survives a mistake but not a dead disk
- Upgrades: Pinned digest, snapshot first, release notes read, rollback rehearsed · an unattended pull of a moving tag is how an analytics box goes quiet
### Data model (create this before Phase 1)
- compose.yml · one service, restart unless-stopped, the image pinned by digest rather than by a moving tag
- volume hitkeep_data mounted at the data path · the live database and the counts you care about
- volume hitkeep_archive mounted at the archive path · retention archives, which grow and need a ceiling
- volume hitkeep_backups mounted at the backup path · the automatic snapshots, on the same disk as the data they protect
- .env holding the public URL and the session signing secret, with a committed .env.example carrying placeholders only
- backup-manifest · for each off-box copy: file, size, checksum, the snapshot time, and the date it was last restore-tested
- runbook.md · the upgrade, the rollback and the restore, written before you need any of them
### Phase 1 · The published image, running and reachable
Build: Write compose.yml for one service from the published image, pinned by digest. Mount named
volumes at the data, archive and backup paths the image documents. Generate the session
signing secret locally and put it in .env beside the public URL, with a placeholder-only
.env.example committed. Bind the port to localhost. Use the documented liveness and readiness
endpoints in the health check, preferring readiness, which reports on the databases rather
than just the process.
Trap: Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
Done when: The container reports healthy against the readiness endpoint, not merely the liveness one. Stopping and recreating the container loses nothing, tested by recreating it and reloading the dashboard. The signing secret exists only in .env and in your password manager, verified by grepping the repository. The service is not reachable on the host's public address, verified from another machine.
Do not build yet: No TLS, no public domain, no tracker tag on a real site.
### Phase 2 · A public URL and honest client addresses
Build: Point a hostname at the box and terminate TLS in the reverse proxy you already run, passing
the forwarded headers through. Set the public URL variable to exactly the address people
type, scheme and all. Set the trusted proxy variable to the proxy's network range so the
real client address is used, and to nothing wider than that.
Trap: Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
Done when: A visit from another network is recorded with that network's address and not the proxy's, verified in the dashboard. A forged forwarded header from an untrusted address does not change the recorded client, tested with curl. The public URL value matches the address in the browser exactly, including scheme, and login sessions survive a reload. The certificate renews automatically and the renewal has been exercised once.
Do not build yet: No backups off the box, no upgrades, no alerting.
### Phase 3 · A backup you have actually restored
Build: Set the snapshot interval and the retention window deliberately rather than accepting the
defaults, noting that the documented retention default is expressed in hours (checked), so
the built-in history is roughly a day deep. Write a script that copies the newest snapshot to
a second destination with a checksum and records it in a manifest. Then restore it onto a
scratch stack on a different path and confirm the dashboard shows the same numbers.
Trap: The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
Done when: A snapshot from the box exists on a second machine with a matching checksum, recorded in the manifest. A scratch stack started from that copy shows the same site list and the same visitor totals as production, performed rather than described. The off-box retention is longer than the container's own and both figures are written in the runbook. Deleting the local backup volume does not lose the off-box generations, tested deliberately.
Do not build yet: No upgrade procedure and no alerting yet.
### Phase 4 · Upgrades you can undo
Build: Write an update script that reads the release notes, takes a snapshot, records the current
digest, pulls the new one, recreates the service and watches the readiness endpoint and the
logs. Write the rollback beside it: stop, restore the pre-upgrade snapshot, pin the recorded
digest, start. Rehearse the rollback once on the scratch stack.
Trap: Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
Done when: The update script refuses to proceed if the pre-upgrade snapshot fails, tested by making it fail. The recorded previous digest is written somewhere outside the container before the pull. A rollback has been performed end to end on the scratch stack and the runbook matches what actually happened. No scheduled job pulls images on this host, verified.
Do not build yet: No alerting or export yet.
### Phase 5 · Knowing it is still collecting, and being able to leave
Build: Add an external check that fires a synthetic event and then asserts it appears, rather than
only asking whether the page returns. Alert on collection stopping, on disk crossing a
threshold, and on the readiness endpoint failing. Watch the archive volume's growth and set a
retention you have decided. Export the data on a schedule in a format that opens without this
application, and write down what you would do if the project stopped being maintained.
Trap: Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want.
Done when: A synthetic event is sent and confirmed present on a schedule, and breaking the tracker tag deliberately raises the alert within the stated window. Disk usage for each of the three volumes is graphed or logged, with an alert threshold below the volume size. A scheduled export produces a file that opens in a spreadsheet or a database without this application. The runbook names who is on call, which is a real answer even when it is one person.
Do not build yet: Nothing after this.
### Out of scope (and why)
- Writing an analytics application, since the one being hosted is open source and reimplementing it replaces a solved problem with an unsolved one.
- Modifying the image, which converts every future release into a merge and every bug report into your bug report.
- Adding an external database, queue or cache, none of which the image asks for and each of which is another thing to back up and upgrade.
- Multi-tenant accounts and billing around the deployment, which is the hosted business and not a self-hosted install.
- Promising anyone an uptime figure, since you are now the person who would have to honour it.
### README must contain
- That this is the published open-source application run unmodified, pinned by digest, and not a reimplementation
- Where every persistent file lives, which volume holds it, and which of them is the one that matters
- That the built-in snapshot retention is short and measured in hours, so the off-box copy is the real backup
- The restore procedure, and the date the restore was last actually performed
- That the upgrade takes a snapshot and records the outgoing digest first, with the rollback written beside it
- The operational responsibilities you have taken on: TLS, upgrades, backups, disk and being the person who notices
===== AGENTS.md =====
# Agent instructions · HitKeep Cloud indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: The published pascalebeier/hitkeep image, unmodified, pinned by digest, Named volumes for the data, archive and backup paths the image expects, Whatever the image embeds, left alone, Bound to localhost with your existing reverse proxy terminating TLS, The built-in snapshots plus a copy on another machine, restore-tested, Pinned digest, snapshot first, release notes read, rollback rehearsed. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
- Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
- The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
- Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
===== BUILD_PLAN.md =====
# Build plan · HitKeep Cloud
A single-container deployment of an MIT-licensed analytics server, where the work is not the application but the proxy configuration, the off-box backup, the tested restore, the pinned upgrade and knowing within a day that collection has stopped.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · The published image, running and reachable
The official container starts, reports ready, and keeps its state in volumes you named.
### Steps
1. Pin the image by digest, not by a tag
A tag moves, and it moves at the worst possible time.
Files: `compose.yml`
```sh
docker image inspect --format '{{index .RepoDigests 0}}' pascalebeier/hitkeep:latest
```
2. Name three volumes and mount them at the documented paths
Data, archive and backups are separate concerns with separate growth.
Files: `compose.yml`
3. Generate the secret locally and commit only a placeholder
A signing secret that reaches a git history has to be rotated, which logs everyone out.
Files: `.env.example`, `.gitignore`
```sh
openssl rand -hex 32
```
### Done when
- [ ] Readiness, not just liveness, reports healthy
- [ ] Recreating the container loses no data
- [ ] No secret appears anywhere in the repository
- [ ] The port is not reachable from outside the host
### Watch out
- Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
## Phase 2 · A public URL and honest client addresses
TLS terminates at your proxy and the counts reflect visitors rather than the proxy.
### Steps
1. Terminate TLS in the proxy you already operate
Two certificate stores is one more than anyone maintains.
Files: `proxy/hitkeep.conf`
2. Set the public URL to the exact address, scheme included
A mismatch here shows up as sessions that will not stick.
Files: `.env`
3. Trust the proxy range and nothing else
Then verify the recorded address from a machine outside your network.
Files: `compose.yml`
```sh
curl -H 'X-Forwarded-For: 1.2.3.4' https://analytics.example.com/
```
### Done when
- [ ] An outside visit records the outside address
- [ ] A forged forwarded header changes nothing
- [ ] Sessions survive a reload on the public URL
- [ ] Certificate renewal has been exercised
### Watch out
- Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
## Phase 3 · A backup you have actually restored
A snapshot exists on another machine and has been brought back up at least once.
### Steps
1. Choose the interval and retention on purpose
The defaults are for a demonstration, not for a year of history.
Files: `compose.yml`, `.env`
2. Copy off the box with a checksum and a manifest
A backup nobody has checksummed is a file of unknown provenance.
Files: `backup.sh`, `backups/manifest.csv`
3. Restore onto a scratch stack and compare totals
This is the step everyone documents and nobody performs.
Files: `restore.sh`, `runbook.md`
### Done when
- [ ] An off-box copy exists with a verified checksum
- [ ] A scratch restore shows matching totals
- [ ] Off-box retention exceeds the container's own
- [ ] Losing the local backup volume loses nothing
### Watch out
- The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
## Phase 4 · Upgrades you can undo
A new image can be tried and reversed without losing a day of data.
### Steps
1. Snapshot, then record the digest, then pull
In that order, because the third step is the only reversible one.
Files: `update.sh`
2. Write the rollback and rehearse it
A rollback first attempted during an outage is a second outage.
Files: `rollback.sh`, `runbook.md`
3. Read the release notes before every upgrade
A one-way migration is always mentioned, and always by someone nobody read.
Files: `runbook.md`
### Done when
- [ ] A failed pre-upgrade snapshot stops the update
- [ ] The previous digest is recorded outside the container
- [ ] A rollback has been performed on the scratch stack
- [ ] Nothing pulls images automatically on this host
### Watch out
- Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
## Not in this build
- Any reimplementation of the application.
- Modifications to the published image.
- An external database, queue or cache.
- Accounts, billing or an uptime promise.
## After v1, if you want it
- A second site on the same instance, once the restore drill has been run with real data in it
- Shipping the export into a warehouse, so long-term analysis never depends on this box being up
- A staging stack fed from a restored snapshot, which makes every upgrade a rehearsal first
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Exactly the address people open, scheme included. A mismatch breaks sessions.
HITKEEP_PUBLIC_URL=https://analytics.example.com
# Required · secret. Session signing. Generated on the host, stored in your password manager, never in git.
HITKEEP_JWT_SECRET=generated-locally-never-committed
# Required. The proxy's network range only. Wider means forged client addresses.
HITKEEP_TRUSTED_PROXIES=172.18.0.0/16
# Required. Snapshot interval in minutes. Set it on purpose rather than accepting the default.
HITKEEP_BACKUP_INTERVAL=60
# Required. Local retention in hours. Short by design, which is why the off-box copy exists.
HITKEEP_BACKUP_RETENTION=72
# Required. Pinned by digest. The update script changes this line and records the old one.
HITKEEP_IMAGE=pascalebeier/hitkeep@sha256:...redacted
# Required. Where the off-box copies land, with more generations than the container keeps.
BACKUP_REMOTE_TARGET=s3://backups.example.com/hitkeep/
You are building a production product version of HitKeep Cloud.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# HitKeep Cloud · product brief
## Problem
The subscription is managed hosting of the same MIT-licensed application, so an agent does not need to reproduce HitKeep. It can deploy the official binary or container in one sitting; the real gap is upgrades, backups, email delivery, monitoring, and being on call for the analytics box.
## Product outcome
The same analytics application the subscription runs, on a host you control, with a restore you have performed, an upgrade you can reverse, and an alert that fires when collection stops rather than when someone asks for last quarter.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- Linux host or Docker
- domain and TLS
- persistent storage
- session signing secret
- backup and update routine
## Explicit non-goals for v1
- Any reimplementation of the application.
- Modifications to the published image.
- An external database, queue or cache.
- Accounts, billing or an uptime promise.
- managed upgrades and monitoring
- encrypted regional backups
- managed email delivery
- vendor support and incident response
## Success criteria
- Readiness rather than liveness is confirmed as the health check
- An outside visit is confirmed to record the outside address
- A scratch restore from an off-box copy is confirmed to match
- A rollback is confirmed rehearsed with the digest recorded
- A deliberately broken tracker is confirmed to raise an alert
===== BRIEF.md =====
# Build brief · HitKeep Cloud
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a self-hosted deployment of the open-source analytics server that the subscription hosts for you to replace HitKeep Cloud. Build it in phases, in the order below.
Do not write the whole thing in one pass. Finish a phase, run its "Done when"
check, fix what fails, and only then start the next phase.
### Rule zero
Do not write an analytics application. HitKeep is MIT licensed and published as a container
image, so the product you are replacing is the hosting, not the software. Every hour spent
reimplementing the dashboard is an hour not spent on the thing you are actually taking on.
What you are taking on is operations, and the honest version of that starts with a restore
rather than ending with one. The image writes its own snapshots on an interval, with a
retention window whose default is stated in hours, not weeks (checked against the published
compose guide), and those snapshots sit in a volume on the same machine as the database they
protect. A restore you have never performed is a belief. Copy a snapshot off the box and bring
it back up on a scratch stack before you put the tracker tag on anything that matters.
### Stack (fixed, do not substitute)
- Application: The published pascalebeier/hitkeep image, unmodified, pinned by digest · modifying it means you own the merge conflicts forever
- Storage: Named volumes for the data, archive and backup paths the image expects · the container is disposable and the volumes are not
- Database: Whatever the image embeds, left alone · the release notes say when a format changes, and no external database is needed or wanted
- Network: Bound to localhost with your existing reverse proxy terminating TLS · one place that holds certificates is better than two
- Backups: The built-in snapshots plus a copy on another machine, restore-tested · a snapshot beside the database survives a mistake but not a dead disk
- Upgrades: Pinned digest, snapshot first, release notes read, rollback rehearsed · an unattended pull of a moving tag is how an analytics box goes quiet
### Data model (create this before Phase 1)
- compose.yml · one service, restart unless-stopped, the image pinned by digest rather than by a moving tag
- volume hitkeep_data mounted at the data path · the live database and the counts you care about
- volume hitkeep_archive mounted at the archive path · retention archives, which grow and need a ceiling
- volume hitkeep_backups mounted at the backup path · the automatic snapshots, on the same disk as the data they protect
- .env holding the public URL and the session signing secret, with a committed .env.example carrying placeholders only
- backup-manifest · for each off-box copy: file, size, checksum, the snapshot time, and the date it was last restore-tested
- runbook.md · the upgrade, the rollback and the restore, written before you need any of them
### Phase 1 · The published image, running and reachable
Build: Write compose.yml for one service from the published image, pinned by digest. Mount named
volumes at the data, archive and backup paths the image documents. Generate the session
signing secret locally and put it in .env beside the public URL, with a placeholder-only
.env.example committed. Bind the port to localhost. Use the documented liveness and readiness
endpoints in the health check, preferring readiness, which reports on the databases rather
than just the process.
Trap: Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
Done when: The container reports healthy against the readiness endpoint, not merely the liveness one. Stopping and recreating the container loses nothing, tested by recreating it and reloading the dashboard. The signing secret exists only in .env and in your password manager, verified by grepping the repository. The service is not reachable on the host's public address, verified from another machine.
Do not build yet: No TLS, no public domain, no tracker tag on a real site.
### Phase 2 · A public URL and honest client addresses
Build: Point a hostname at the box and terminate TLS in the reverse proxy you already run, passing
the forwarded headers through. Set the public URL variable to exactly the address people
type, scheme and all. Set the trusted proxy variable to the proxy's network range so the
real client address is used, and to nothing wider than that.
Trap: Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
Done when: A visit from another network is recorded with that network's address and not the proxy's, verified in the dashboard. A forged forwarded header from an untrusted address does not change the recorded client, tested with curl. The public URL value matches the address in the browser exactly, including scheme, and login sessions survive a reload. The certificate renews automatically and the renewal has been exercised once.
Do not build yet: No backups off the box, no upgrades, no alerting.
### Phase 3 · A backup you have actually restored
Build: Set the snapshot interval and the retention window deliberately rather than accepting the
defaults, noting that the documented retention default is expressed in hours (checked), so
the built-in history is roughly a day deep. Write a script that copies the newest snapshot to
a second destination with a checksum and records it in a manifest. Then restore it onto a
scratch stack on a different path and confirm the dashboard shows the same numbers.
Trap: The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
Done when: A snapshot from the box exists on a second machine with a matching checksum, recorded in the manifest. A scratch stack started from that copy shows the same site list and the same visitor totals as production, performed rather than described. The off-box retention is longer than the container's own and both figures are written in the runbook. Deleting the local backup volume does not lose the off-box generations, tested deliberately.
Do not build yet: No upgrade procedure and no alerting yet.
### Phase 4 · Upgrades you can undo
Build: Write an update script that reads the release notes, takes a snapshot, records the current
digest, pulls the new one, recreates the service and watches the readiness endpoint and the
logs. Write the rollback beside it: stop, restore the pre-upgrade snapshot, pin the recorded
digest, start. Rehearse the rollback once on the scratch stack.
Trap: Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
Done when: The update script refuses to proceed if the pre-upgrade snapshot fails, tested by making it fail. The recorded previous digest is written somewhere outside the container before the pull. A rollback has been performed end to end on the scratch stack and the runbook matches what actually happened. No scheduled job pulls images on this host, verified.
Do not build yet: No alerting or export yet.
### Phase 5 · Knowing it is still collecting, and being able to leave
Build: Add an external check that fires a synthetic event and then asserts it appears, rather than
only asking whether the page returns. Alert on collection stopping, on disk crossing a
threshold, and on the readiness endpoint failing. Watch the archive volume's growth and set a
retention you have decided. Export the data on a schedule in a format that opens without this
application, and write down what you would do if the project stopped being maintained.
Trap: Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want.
Done when: A synthetic event is sent and confirmed present on a schedule, and breaking the tracker tag deliberately raises the alert within the stated window. Disk usage for each of the three volumes is graphed or logged, with an alert threshold below the volume size. A scheduled export produces a file that opens in a spreadsheet or a database without this application. The runbook names who is on call, which is a real answer even when it is one person.
Do not build yet: Nothing after this.
### Out of scope (and why)
- Writing an analytics application, since the one being hosted is open source and reimplementing it replaces a solved problem with an unsolved one.
- Modifying the image, which converts every future release into a merge and every bug report into your bug report.
- Adding an external database, queue or cache, none of which the image asks for and each of which is another thing to back up and upgrade.
- Multi-tenant accounts and billing around the deployment, which is the hosted business and not a self-hosted install.
- Promising anyone an uptime figure, since you are now the person who would have to honour it.
### README must contain
- That this is the published open-source application run unmodified, pinned by digest, and not a reimplementation
- Where every persistent file lives, which volume holds it, and which of them is the one that matters
- That the built-in snapshot retention is short and measured in hours, so the off-box copy is the real backup
- The restore procedure, and the date the restore was last actually performed
- That the upgrade takes a snapshot and records the outgoing digest first, with the rollback written beside it
- The operational responsibilities you have taken on: TLS, upgrades, backups, disk and being the person who notices
===== ARCHITECTURE.md =====
# Architecture · HitKeep Cloud
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Application | The published pascalebeier/hitkeep image, unmodified, pinned by digest | modifying it means you own the merge conflicts forever |
| Storage | Named volumes for the data, archive and backup paths the image expects | the container is disposable and the volumes are not |
| Database | Whatever the image embeds, left alone | the release notes say when a format changes, and no external database is needed or wanted |
| Network | Bound to localhost with your existing reverse proxy terminating TLS | one place that holds certificates is better than two |
| Backups | The built-in snapshots plus a copy on another machine, restore-tested | a snapshot beside the database survives a mistake but not a dead disk |
| Upgrades | Pinned digest, snapshot first, release notes read, rollback rehearsed | an unattended pull of a moving tag is how an analytics box goes quiet |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Application | The published image, unmodified and pinned. | A newer digest after reading the release notes. Never a fork. |
| Proxy | TLS, the hostname and forwarded headers. | Any proxy, provided the trusted range stays narrow. |
| Volumes | Data, archive and backup paths. | Bind mounts instead of named volumes, provided the backup still leaves the box. |
| Backups | Off-box copies, checksums and the restore drill. | Any transport, provided a restore has actually been performed. |
| Checks | Synthetic events, disk thresholds, readiness. | Any monitoring, provided it asserts an event lands rather than a page loads. |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `HITKEEP_PUBLIC_URL` · required · Exactly the address people open, scheme included. A mismatch breaks sessions.
- `HITKEEP_JWT_SECRET` · required, secret · Session signing. Generated on the host, stored in your password manager, never in git.
- `HITKEEP_TRUSTED_PROXIES` · required · The proxy's network range only. Wider means forged client addresses.
- `HITKEEP_BACKUP_INTERVAL` · required · Snapshot interval in minutes. Set it on purpose rather than accepting the default.
- `HITKEEP_BACKUP_RETENTION` · required · Local retention in hours. Short by design, which is why the off-box copy exists.
- `HITKEEP_IMAGE` · required · Pinned by digest. The update script changes this line and records the old one.
- `BACKUP_REMOTE_TARGET` · required · Where the off-box copies land, with more generations than the container keeps.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · HitKeep Cloud product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: The published pascalebeier/hitkeep image, unmodified, pinned by digest, Named volumes for the data, archive and backup paths the image expects, Whatever the image embeds, left alone, Bound to localhost with your existing reverse proxy terminating TLS, The built-in snapshots plus a copy on another machine, restore-tested, Pinned digest, snapshot first, release notes read, rollback rehearsed.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
## Known traps
- Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
- Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
- The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
- Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
- Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want.
===== MILESTONES.md =====
# Delivery milestones · HitKeep Cloud
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · The published image, running and reachable
The official container starts, reports ready, and keeps its state in volumes you named.
### Steps
1. Pin the image by digest, not by a tag
A tag moves, and it moves at the worst possible time.
Files: `compose.yml`
```sh
docker image inspect --format '{{index .RepoDigests 0}}' pascalebeier/hitkeep:latest
```
2. Name three volumes and mount them at the documented paths
Data, archive and backups are separate concerns with separate growth.
Files: `compose.yml`
3. Generate the secret locally and commit only a placeholder
A signing secret that reaches a git history has to be rotated, which logs everyone out.
Files: `.env.example`, `.gitignore`
```sh
openssl rand -hex 32
```
### Done when
- [ ] Readiness, not just liveness, reports healthy
- [ ] Recreating the container loses no data
- [ ] No secret appears anywhere in the repository
- [ ] The port is not reachable from outside the host
### Watch out
- Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
## M2 · A public URL and honest client addresses
TLS terminates at your proxy and the counts reflect visitors rather than the proxy.
### Steps
1. Terminate TLS in the proxy you already operate
Two certificate stores is one more than anyone maintains.
Files: `proxy/hitkeep.conf`
2. Set the public URL to the exact address, scheme included
A mismatch here shows up as sessions that will not stick.
Files: `.env`
3. Trust the proxy range and nothing else
Then verify the recorded address from a machine outside your network.
Files: `compose.yml`
```sh
curl -H 'X-Forwarded-For: 1.2.3.4' https://analytics.example.com/
```
### Done when
- [ ] An outside visit records the outside address
- [ ] A forged forwarded header changes nothing
- [ ] Sessions survive a reload on the public URL
- [ ] Certificate renewal has been exercised
### Watch out
- Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
## M3 · A backup you have actually restored
A snapshot exists on another machine and has been brought back up at least once.
### Steps
1. Choose the interval and retention on purpose
The defaults are for a demonstration, not for a year of history.
Files: `compose.yml`, `.env`
2. Copy off the box with a checksum and a manifest
A backup nobody has checksummed is a file of unknown provenance.
Files: `backup.sh`, `backups/manifest.csv`
3. Restore onto a scratch stack and compare totals
This is the step everyone documents and nobody performs.
Files: `restore.sh`, `runbook.md`
### Done when
- [ ] An off-box copy exists with a verified checksum
- [ ] A scratch restore shows matching totals
- [ ] Off-box retention exceeds the container's own
- [ ] Losing the local backup volume loses nothing
### Watch out
- The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
## M4 · Upgrades you can undo
A new image can be tried and reversed without losing a day of data.
### Steps
1. Snapshot, then record the digest, then pull
In that order, because the third step is the only reversible one.
Files: `update.sh`
2. Write the rollback and rehearse it
A rollback first attempted during an outage is a second outage.
Files: `rollback.sh`, `runbook.md`
3. Read the release notes before every upgrade
A one-way migration is always mentioned, and always by someone nobody read.
Files: `runbook.md`
### Done when
- [ ] A failed pre-upgrade snapshot stops the update
- [ ] The previous digest is recorded outside the container
- [ ] A rollback has been performed on the scratch stack
- [ ] Nothing pulls images automatically on this host
### Watch out
- Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
## M5 · Knowing it is still collecting, and being able to leave (production only)
Silence is detected within a day, disk growth is bounded, and the data can walk out.
### Steps
1. Assert a synthetic event lands
A page returning two hundred proves the page returns two hundred.
Files: `checks/synthetic.sh`
2. Alert on collection stopping and on disk
The two failures that are silent until they are expensive.
Files: `checks/alerts.yml`, `runbook.md`
3. Export on a schedule to something portable
The exit is worth building while you are not in a hurry to leave.
Files: `export.sh`
### Done when
- [ ] A broken tracker raises an alert within the window
- [ ] Disk thresholds exist below the volume size
- [ ] A scheduled export opens without this application
- [ ] The runbook names the person on call
### Watch out
- Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want.
===== OPERATIONS.md =====
# Operations · HitKeep Cloud
## Backup
The container snapshots on an interval into its own volume with a retention measured in hours, which is a starting point rather than a backup. The real backup is a scheduled copy to a second machine with a checksum, a manifest and more generations than the local window.
## Restore
Stop the service, restore the snapshot into the data volume, start, and compare the site list and visitor totals against what you expect. Do this once on a scratch stack before you need it, and write the date in the runbook.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
Readiness rather than liveness, a synthetic event asserted to land, disk usage on all three volumes with a threshold below the volume size, and the age of the newest off-box backup.
## Incident checklist
The characteristic failure is silence: healthy container, working dashboard, no events, because a tag changed or the proxy stopped forwarding. The synthetic check is what turns that from a quarterly surprise into a same-day alert.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] Readiness rather than liveness is confirmed as the health check
- [ ] An outside visit is confirmed to record the outside address
- [ ] A scratch restore from an off-box copy is confirmed to match
- [ ] A rollback is confirmed rehearsed with the digest recorded
- [ ] A deliberately broken tracker is confirmed to raise an alert
## Launch constraint
Do not market omitted HitKeep Cloud capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Exactly the address people open, scheme included. A mismatch breaks sessions.
HITKEEP_PUBLIC_URL=https://analytics.example.com
# Required · secret. Session signing. Generated on the host, stored in your password manager, never in git.
HITKEEP_JWT_SECRET=generated-locally-never-committed
# Required. The proxy's network range only. Wider means forged client addresses.
HITKEEP_TRUSTED_PROXIES=172.18.0.0/16
# Required. Snapshot interval in minutes. Set it on purpose rather than accepting the default.
HITKEEP_BACKUP_INTERVAL=60
# Required. Local retention in hours. Short by design, which is why the off-box copy exists.
HITKEEP_BACKUP_RETENTION=72
# Required. Pinned by digest. The update script changes this line and records the old one.
HITKEEP_IMAGE=pascalebeier/hitkeep@sha256:...redacted
# Required. Where the off-box copies land, with more generations than the container keeps.
BACKUP_REMOTE_TARGET=s3://backups.example.com/hitkeep/
# HitKeep Cloud · indie build
A single-container deployment of an MIT-licensed analytics server, where the work is not the application but the proxy configuration, the off-box backup, the tested restore, the pinned upgrade and knowing within a day that collection has stopped.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Application | The published pascalebeier/hitkeep image, unmodified, pinned by digest | modifying it means you own the merge conflicts forever |
| Storage | Named volumes for the data, archive and backup paths the image expects | the container is disposable and the volumes are not |
| Database | Whatever the image embeds, left alone | the release notes say when a format changes, and no external database is needed or wanted |
| Network | Bound to localhost with your existing reverse proxy terminating TLS | one place that holds certificates is better than two |
| Backups | The built-in snapshots plus a copy on another machine, restore-tested | a snapshot beside the database survives a mistake but not a dead disk |
| Upgrades | Pinned digest, snapshot first, release notes read, rollback rehearsed | an unattended pull of a moving tag is how an analytics box goes quiet |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Docker Engine with the Compose plugin** · free
- Why: Runs the published image and manages the named volumes.
- Get it: Install from your distribution's repository or docker.com.
- Verify: docker compose version prints a version
- [ ] **A small always-on Linux host** · a few pounds a month
- Why: Analytics has to collect while nobody is watching, which is exactly the property a laptop does not have.
- Get it: Any small virtual machine, or a home server with a static power supply.
- Verify: The host survives a reboot with the service coming back automatically
- [ ] **A hostname and a reverse proxy that terminates TLS** · the price of a domain
- Why: The public URL value has to match the address people use exactly, and the tracker script is loaded from it.
- Get it: A subdomain pointed at the host, served by the proxy you already run.
- Verify: The hostname resolves and serves a valid certificate
- [ ] **A second destination for backups** · pennies per month
- Why: The container's own snapshots sit on the same disk as the database, which covers a mistake but not a dead machine.
- Get it: Object storage, another server, or a drive that is not in the same box.
- Verify: A snapshot copied there has a matching checksum and has been restored once
- [ ] **Retention, alert thresholds and who is on call** · free
- Why: These are the subscription you are replacing, and leaving them undecided is how a self-hosted analytics box quietly stops collecting.
- Get it: Write down how long data is kept, at what disk level you want warning, and whose phone rings.
- Verify: All three appear in the runbook and in the alert configuration
## Quick start
```sh
docker image inspect --format '{{index .RepoDigests 0}}' pascalebeier/hitkeep:latest
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Any reimplementation of the application.
- Modifications to the published image.
- An external database, queue or cache.
- Accounts, billing or an uptime promise.
- managed upgrades and monitoring
- encrypted regional backups
- managed email delivery
- vendor support and incident response
If one of those is essential to you, that is the reason to keep paying for HitKeep Cloud, and the README should say so rather than pretend.# Build brief · HitKeep Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a self-hosted deployment of the open-source analytics server that the subscription hosts for you to replace HitKeep Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not write an analytics application. HitKeep is MIT licensed and published as a container image, so the product you are replacing is the hosting, not the software. Every hour spent reimplementing the dashboard is an hour not spent on the thing you are actually taking on. What you are taking on is operations, and the honest version of that starts with a restore rather than ending with one. The image writes its own snapshots on an interval, with a retention window whose default is stated in hours, not weeks (checked against the published compose guide), and those snapshots sit in a volume on the same machine as the database they protect. A restore you have never performed is a belief. Copy a snapshot off the box and bring it back up on a scratch stack before you put the tracker tag on anything that matters. ### Stack (fixed, do not substitute) - Application: The published pascalebeier/hitkeep image, unmodified, pinned by digest · modifying it means you own the merge conflicts forever - Storage: Named volumes for the data, archive and backup paths the image expects · the container is disposable and the volumes are not - Database: Whatever the image embeds, left alone · the release notes say when a format changes, and no external database is needed or wanted - Network: Bound to localhost with your existing reverse proxy terminating TLS · one place that holds certificates is better than two - Backups: The built-in snapshots plus a copy on another machine, restore-tested · a snapshot beside the database survives a mistake but not a dead disk - Upgrades: Pinned digest, snapshot first, release notes read, rollback rehearsed · an unattended pull of a moving tag is how an analytics box goes quiet ### Data model (create this before Phase 1) - compose.yml · one service, restart unless-stopped, the image pinned by digest rather than by a moving tag - volume hitkeep_data mounted at the data path · the live database and the counts you care about - volume hitkeep_archive mounted at the archive path · retention archives, which grow and need a ceiling - volume hitkeep_backups mounted at the backup path · the automatic snapshots, on the same disk as the data they protect - .env holding the public URL and the session signing secret, with a committed .env.example carrying placeholders only - backup-manifest · for each off-box copy: file, size, checksum, the snapshot time, and the date it was last restore-tested - runbook.md · the upgrade, the rollback and the restore, written before you need any of them ### Phase 1 · The published image, running and reachable Build: Write compose.yml for one service from the published image, pinned by digest. Mount named volumes at the data, archive and backup paths the image documents. Generate the session signing secret locally and put it in .env beside the public URL, with a placeholder-only .env.example committed. Bind the port to localhost. Use the documented liveness and readiness endpoints in the health check, preferring readiness, which reports on the databases rather than just the process. Trap: Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week. Done when: The container reports healthy against the readiness endpoint, not merely the liveness one. Stopping and recreating the container loses nothing, tested by recreating it and reloading the dashboard. The signing secret exists only in .env and in your password manager, verified by grepping the repository. The service is not reachable on the host's public address, verified from another machine. Do not build yet: No TLS, no public domain, no tracker tag on a real site. ### Phase 2 · A public URL and honest client addresses Build: Point a hostname at the box and terminate TLS in the reverse proxy you already run, passing the forwarded headers through. Set the public URL variable to exactly the address people type, scheme and all. Set the trusted proxy variable to the proxy's network range so the real client address is used, and to nothing wider than that. Trap: Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine. Done when: A visit from another network is recorded with that network's address and not the proxy's, verified in the dashboard. A forged forwarded header from an untrusted address does not change the recorded client, tested with curl. The public URL value matches the address in the browser exactly, including scheme, and login sessions survive a reload. The certificate renews automatically and the renewal has been exercised once. Do not build yet: No backups off the box, no upgrades, no alerting. ### Phase 3 · A backup you have actually restored Build: Set the snapshot interval and the retention window deliberately rather than accepting the defaults, noting that the documented retention default is expressed in hours (checked), so the built-in history is roughly a day deep. Write a script that copies the newest snapshot to a second destination with a checksum and records it in a manifest. Then restore it onto a scratch stack on a different path and confirm the dashboard shows the same numbers. Trap: The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored. Done when: A snapshot from the box exists on a second machine with a matching checksum, recorded in the manifest. A scratch stack started from that copy shows the same site list and the same visitor totals as production, performed rather than described. The off-box retention is longer than the container's own and both figures are written in the runbook. Deleting the local backup volume does not lose the off-box generations, tested deliberately. Do not build yet: No upgrade procedure and no alerting yet. ### Phase 4 · Upgrades you can undo Build: Write an update script that reads the release notes, takes a snapshot, records the current digest, pulls the new one, recreates the service and watches the readiness endpoint and the logs. Write the rollback beside it: stop, restore the pre-upgrade snapshot, pin the recorded digest, start. Rehearse the rollback once on the scratch stack. Trap: Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box. Done when: The update script refuses to proceed if the pre-upgrade snapshot fails, tested by making it fail. The recorded previous digest is written somewhere outside the container before the pull. A rollback has been performed end to end on the scratch stack and the runbook matches what actually happened. No scheduled job pulls images on this host, verified. Do not build yet: No alerting or export yet. ### Phase 5 · Knowing it is still collecting, and being able to leave Build: Add an external check that fires a synthetic event and then asserts it appears, rather than only asking whether the page returns. Alert on collection stopping, on disk crossing a threshold, and on the readiness endpoint failing. Watch the archive volume's growth and set a retention you have decided. Export the data on a schedule in a format that opens without this application, and write down what you would do if the project stopped being maintained. Trap: Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want. Done when: A synthetic event is sent and confirmed present on a schedule, and breaking the tracker tag deliberately raises the alert within the stated window. Disk usage for each of the three volumes is graphed or logged, with an alert threshold below the volume size. A scheduled export produces a file that opens in a spreadsheet or a database without this application. The runbook names who is on call, which is a real answer even when it is one person. Do not build yet: Nothing after this. ### Out of scope (and why) - Writing an analytics application, since the one being hosted is open source and reimplementing it replaces a solved problem with an unsolved one. - Modifying the image, which converts every future release into a merge and every bug report into your bug report. - Adding an external database, queue or cache, none of which the image asks for and each of which is another thing to back up and upgrade. - Multi-tenant accounts and billing around the deployment, which is the hosted business and not a self-hosted install. - Promising anyone an uptime figure, since you are now the person who would have to honour it. ### README must contain - That this is the published open-source application run unmodified, pinned by digest, and not a reimplementation - Where every persistent file lives, which volume holds it, and which of them is the one that matters - That the built-in snapshot retention is short and measured in hours, so the off-box copy is the real backup - The restore procedure, and the date the restore was last actually performed - That the upgrade takes a snapshot and records the outgoing digest first, with the rollback written beside it - The operational responsibilities you have taken on: TLS, upgrades, backups, disk and being the person who notices
# Agent instructions · HitKeep Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: The published pascalebeier/hitkeep image, unmodified, pinned by digest, Named volumes for the data, archive and backup paths the image expects, Whatever the image embeds, left alone, Bound to localhost with your existing reverse proxy terminating TLS, The built-in snapshots plus a copy on another machine, restore-tested, Pinned digest, snapshot first, release notes read, rollback rehearsed. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week. - Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine. - The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored. - Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
# Build plan · HitKeep Cloud
A single-container deployment of an MIT-licensed analytics server, where the work is not the application but the proxy configuration, the off-box backup, the tested restore, the pinned upgrade and knowing within a day that collection has stopped.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · The published image, running and reachable
The official container starts, reports ready, and keeps its state in volumes you named.
### Steps
1. Pin the image by digest, not by a tag
A tag moves, and it moves at the worst possible time.
Files: `compose.yml`
```sh
docker image inspect --format '{{index .RepoDigests 0}}' pascalebeier/hitkeep:latest
```
2. Name three volumes and mount them at the documented paths
Data, archive and backups are separate concerns with separate growth.
Files: `compose.yml`
3. Generate the secret locally and commit only a placeholder
A signing secret that reaches a git history has to be rotated, which logs everyone out.
Files: `.env.example`, `.gitignore`
```sh
openssl rand -hex 32
```
### Done when
- [ ] Readiness, not just liveness, reports healthy
- [ ] Recreating the container loses no data
- [ ] No secret appears anywhere in the repository
- [ ] The port is not reachable from outside the host
### Watch out
- Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
## Phase 2 · A public URL and honest client addresses
TLS terminates at your proxy and the counts reflect visitors rather than the proxy.
### Steps
1. Terminate TLS in the proxy you already operate
Two certificate stores is one more than anyone maintains.
Files: `proxy/hitkeep.conf`
2. Set the public URL to the exact address, scheme included
A mismatch here shows up as sessions that will not stick.
Files: `.env`
3. Trust the proxy range and nothing else
Then verify the recorded address from a machine outside your network.
Files: `compose.yml`
```sh
curl -H 'X-Forwarded-For: 1.2.3.4' https://analytics.example.com/
```
### Done when
- [ ] An outside visit records the outside address
- [ ] A forged forwarded header changes nothing
- [ ] Sessions survive a reload on the public URL
- [ ] Certificate renewal has been exercised
### Watch out
- Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
## Phase 3 · A backup you have actually restored
A snapshot exists on another machine and has been brought back up at least once.
### Steps
1. Choose the interval and retention on purpose
The defaults are for a demonstration, not for a year of history.
Files: `compose.yml`, `.env`
2. Copy off the box with a checksum and a manifest
A backup nobody has checksummed is a file of unknown provenance.
Files: `backup.sh`, `backups/manifest.csv`
3. Restore onto a scratch stack and compare totals
This is the step everyone documents and nobody performs.
Files: `restore.sh`, `runbook.md`
### Done when
- [ ] An off-box copy exists with a verified checksum
- [ ] A scratch restore shows matching totals
- [ ] Off-box retention exceeds the container's own
- [ ] Losing the local backup volume loses nothing
### Watch out
- The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
## Phase 4 · Upgrades you can undo
A new image can be tried and reversed without losing a day of data.
### Steps
1. Snapshot, then record the digest, then pull
In that order, because the third step is the only reversible one.
Files: `update.sh`
2. Write the rollback and rehearse it
A rollback first attempted during an outage is a second outage.
Files: `rollback.sh`, `runbook.md`
3. Read the release notes before every upgrade
A one-way migration is always mentioned, and always by someone nobody read.
Files: `runbook.md`
### Done when
- [ ] A failed pre-upgrade snapshot stops the update
- [ ] The previous digest is recorded outside the container
- [ ] A rollback has been performed on the scratch stack
- [ ] Nothing pulls images automatically on this host
### Watch out
- Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
## Not in this build
- Any reimplementation of the application.
- Modifications to the published image.
- An external database, queue or cache.
- Accounts, billing or an uptime promise.
## After v1, if you want it
- A second site on the same instance, once the restore drill has been run with real data in it
- Shipping the export into a warehouse, so long-term analysis never depends on this box being up
- A staging stack fed from a restored snapshot, which makes every upgrade a rehearsal first# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Exactly the address people open, scheme included. A mismatch breaks sessions. HITKEEP_PUBLIC_URL=https://analytics.example.com # Required · secret. Session signing. Generated on the host, stored in your password manager, never in git. HITKEEP_JWT_SECRET=generated-locally-never-committed # Required. The proxy's network range only. Wider means forged client addresses. HITKEEP_TRUSTED_PROXIES=172.18.0.0/16 # Required. Snapshot interval in minutes. Set it on purpose rather than accepting the default. HITKEEP_BACKUP_INTERVAL=60 # Required. Local retention in hours. Short by design, which is why the off-box copy exists. HITKEEP_BACKUP_RETENTION=72 # Required. Pinned by digest. The update script changes this line and records the old one. HITKEEP_IMAGE=pascalebeier/hitkeep@sha256:...redacted # Required. Where the off-box copies land, with more generations than the container keeps. BACKUP_REMOTE_TARGET=s3://backups.example.com/hitkeep/
# HitKeep Cloud · product brief ## Problem The subscription is managed hosting of the same MIT-licensed application, so an agent does not need to reproduce HitKeep. It can deploy the official binary or container in one sitting; the real gap is upgrades, backups, email delivery, monitoring, and being on call for the analytics box. ## Product outcome The same analytics application the subscription runs, on a host you control, with a restore you have performed, an upgrade you can reverse, and an alert that fires when collection stops rather than when someone asks for last quarter. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Linux host or Docker - domain and TLS - persistent storage - session signing secret - backup and update routine ## Explicit non-goals for v1 - Any reimplementation of the application. - Modifications to the published image. - An external database, queue or cache. - Accounts, billing or an uptime promise. - managed upgrades and monitoring - encrypted regional backups - managed email delivery - vendor support and incident response ## Success criteria - Readiness rather than liveness is confirmed as the health check - An outside visit is confirmed to record the outside address - A scratch restore from an off-box copy is confirmed to match - A rollback is confirmed rehearsed with the digest recorded - A deliberately broken tracker is confirmed to raise an alert
# Build brief · HitKeep Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a self-hosted deployment of the open-source analytics server that the subscription hosts for you to replace HitKeep Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not write an analytics application. HitKeep is MIT licensed and published as a container image, so the product you are replacing is the hosting, not the software. Every hour spent reimplementing the dashboard is an hour not spent on the thing you are actually taking on. What you are taking on is operations, and the honest version of that starts with a restore rather than ending with one. The image writes its own snapshots on an interval, with a retention window whose default is stated in hours, not weeks (checked against the published compose guide), and those snapshots sit in a volume on the same machine as the database they protect. A restore you have never performed is a belief. Copy a snapshot off the box and bring it back up on a scratch stack before you put the tracker tag on anything that matters. ### Stack (fixed, do not substitute) - Application: The published pascalebeier/hitkeep image, unmodified, pinned by digest · modifying it means you own the merge conflicts forever - Storage: Named volumes for the data, archive and backup paths the image expects · the container is disposable and the volumes are not - Database: Whatever the image embeds, left alone · the release notes say when a format changes, and no external database is needed or wanted - Network: Bound to localhost with your existing reverse proxy terminating TLS · one place that holds certificates is better than two - Backups: The built-in snapshots plus a copy on another machine, restore-tested · a snapshot beside the database survives a mistake but not a dead disk - Upgrades: Pinned digest, snapshot first, release notes read, rollback rehearsed · an unattended pull of a moving tag is how an analytics box goes quiet ### Data model (create this before Phase 1) - compose.yml · one service, restart unless-stopped, the image pinned by digest rather than by a moving tag - volume hitkeep_data mounted at the data path · the live database and the counts you care about - volume hitkeep_archive mounted at the archive path · retention archives, which grow and need a ceiling - volume hitkeep_backups mounted at the backup path · the automatic snapshots, on the same disk as the data they protect - .env holding the public URL and the session signing secret, with a committed .env.example carrying placeholders only - backup-manifest · for each off-box copy: file, size, checksum, the snapshot time, and the date it was last restore-tested - runbook.md · the upgrade, the rollback and the restore, written before you need any of them ### Phase 1 · The published image, running and reachable Build: Write compose.yml for one service from the published image, pinned by digest. Mount named volumes at the data, archive and backup paths the image documents. Generate the session signing secret locally and put it in .env beside the public URL, with a placeholder-only .env.example committed. Bind the port to localhost. Use the documented liveness and readiness endpoints in the health check, preferring readiness, which reports on the databases rather than just the process. Trap: Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week. Done when: The container reports healthy against the readiness endpoint, not merely the liveness one. Stopping and recreating the container loses nothing, tested by recreating it and reloading the dashboard. The signing secret exists only in .env and in your password manager, verified by grepping the repository. The service is not reachable on the host's public address, verified from another machine. Do not build yet: No TLS, no public domain, no tracker tag on a real site. ### Phase 2 · A public URL and honest client addresses Build: Point a hostname at the box and terminate TLS in the reverse proxy you already run, passing the forwarded headers through. Set the public URL variable to exactly the address people type, scheme and all. Set the trusted proxy variable to the proxy's network range so the real client address is used, and to nothing wider than that. Trap: Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine. Done when: A visit from another network is recorded with that network's address and not the proxy's, verified in the dashboard. A forged forwarded header from an untrusted address does not change the recorded client, tested with curl. The public URL value matches the address in the browser exactly, including scheme, and login sessions survive a reload. The certificate renews automatically and the renewal has been exercised once. Do not build yet: No backups off the box, no upgrades, no alerting. ### Phase 3 · A backup you have actually restored Build: Set the snapshot interval and the retention window deliberately rather than accepting the defaults, noting that the documented retention default is expressed in hours (checked), so the built-in history is roughly a day deep. Write a script that copies the newest snapshot to a second destination with a checksum and records it in a manifest. Then restore it onto a scratch stack on a different path and confirm the dashboard shows the same numbers. Trap: The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored. Done when: A snapshot from the box exists on a second machine with a matching checksum, recorded in the manifest. A scratch stack started from that copy shows the same site list and the same visitor totals as production, performed rather than described. The off-box retention is longer than the container's own and both figures are written in the runbook. Deleting the local backup volume does not lose the off-box generations, tested deliberately. Do not build yet: No upgrade procedure and no alerting yet. ### Phase 4 · Upgrades you can undo Build: Write an update script that reads the release notes, takes a snapshot, records the current digest, pulls the new one, recreates the service and watches the readiness endpoint and the logs. Write the rollback beside it: stop, restore the pre-upgrade snapshot, pin the recorded digest, start. Rehearse the rollback once on the scratch stack. Trap: Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box. Done when: The update script refuses to proceed if the pre-upgrade snapshot fails, tested by making it fail. The recorded previous digest is written somewhere outside the container before the pull. A rollback has been performed end to end on the scratch stack and the runbook matches what actually happened. No scheduled job pulls images on this host, verified. Do not build yet: No alerting or export yet. ### Phase 5 · Knowing it is still collecting, and being able to leave Build: Add an external check that fires a synthetic event and then asserts it appears, rather than only asking whether the page returns. Alert on collection stopping, on disk crossing a threshold, and on the readiness endpoint failing. Watch the archive volume's growth and set a retention you have decided. Export the data on a schedule in a format that opens without this application, and write down what you would do if the project stopped being maintained. Trap: Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want. Done when: A synthetic event is sent and confirmed present on a schedule, and breaking the tracker tag deliberately raises the alert within the stated window. Disk usage for each of the three volumes is graphed or logged, with an alert threshold below the volume size. A scheduled export produces a file that opens in a spreadsheet or a database without this application. The runbook names who is on call, which is a real answer even when it is one person. Do not build yet: Nothing after this. ### Out of scope (and why) - Writing an analytics application, since the one being hosted is open source and reimplementing it replaces a solved problem with an unsolved one. - Modifying the image, which converts every future release into a merge and every bug report into your bug report. - Adding an external database, queue or cache, none of which the image asks for and each of which is another thing to back up and upgrade. - Multi-tenant accounts and billing around the deployment, which is the hosted business and not a self-hosted install. - Promising anyone an uptime figure, since you are now the person who would have to honour it. ### README must contain - That this is the published open-source application run unmodified, pinned by digest, and not a reimplementation - Where every persistent file lives, which volume holds it, and which of them is the one that matters - That the built-in snapshot retention is short and measured in hours, so the off-box copy is the real backup - The restore procedure, and the date the restore was last actually performed - That the upgrade takes a snapshot and records the outgoing digest first, with the rollback written beside it - The operational responsibilities you have taken on: TLS, upgrades, backups, disk and being the person who notices
# Architecture · HitKeep Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Application | The published pascalebeier/hitkeep image, unmodified, pinned by digest | modifying it means you own the merge conflicts forever | | Storage | Named volumes for the data, archive and backup paths the image expects | the container is disposable and the volumes are not | | Database | Whatever the image embeds, left alone | the release notes say when a format changes, and no external database is needed or wanted | | Network | Bound to localhost with your existing reverse proxy terminating TLS | one place that holds certificates is better than two | | Backups | The built-in snapshots plus a copy on another machine, restore-tested | a snapshot beside the database survives a mistake but not a dead disk | | Upgrades | Pinned digest, snapshot first, release notes read, rollback rehearsed | an unattended pull of a moving tag is how an analytics box goes quiet | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Application | The published image, unmodified and pinned. | A newer digest after reading the release notes. Never a fork. | | Proxy | TLS, the hostname and forwarded headers. | Any proxy, provided the trusted range stays narrow. | | Volumes | Data, archive and backup paths. | Bind mounts instead of named volumes, provided the backup still leaves the box. | | Backups | Off-box copies, checksums and the restore drill. | Any transport, provided a restore has actually been performed. | | Checks | Synthetic events, disk thresholds, readiness. | Any monitoring, provided it asserts an event lands rather than a page loads. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `HITKEEP_PUBLIC_URL` · required · Exactly the address people open, scheme included. A mismatch breaks sessions. - `HITKEEP_JWT_SECRET` · required, secret · Session signing. Generated on the host, stored in your password manager, never in git. - `HITKEEP_TRUSTED_PROXIES` · required · The proxy's network range only. Wider means forged client addresses. - `HITKEEP_BACKUP_INTERVAL` · required · Snapshot interval in minutes. Set it on purpose rather than accepting the default. - `HITKEEP_BACKUP_RETENTION` · required · Local retention in hours. Short by design, which is why the off-box copy exists. - `HITKEEP_IMAGE` · required · Pinned by digest. The update script changes this line and records the old one. - `BACKUP_REMOTE_TARGET` · required · Where the off-box copies land, with more generations than the container keeps. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · HitKeep Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: The published pascalebeier/hitkeep image, unmodified, pinned by digest, Named volumes for the data, archive and backup paths the image expects, Whatever the image embeds, left alone, Bound to localhost with your existing reverse proxy terminating TLS, The built-in snapshots plus a copy on another machine, restore-tested, Pinned digest, snapshot first, release notes read, rollback rehearsed. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week. - Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine. - The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored. - Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box. - Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want.
# Delivery milestones · HitKeep Cloud
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · The published image, running and reachable
The official container starts, reports ready, and keeps its state in volumes you named.
### Steps
1. Pin the image by digest, not by a tag
A tag moves, and it moves at the worst possible time.
Files: `compose.yml`
```sh
docker image inspect --format '{{index .RepoDigests 0}}' pascalebeier/hitkeep:latest
```
2. Name three volumes and mount them at the documented paths
Data, archive and backups are separate concerns with separate growth.
Files: `compose.yml`
3. Generate the secret locally and commit only a placeholder
A signing secret that reaches a git history has to be rotated, which logs everyone out.
Files: `.env.example`, `.gitignore`
```sh
openssl rand -hex 32
```
### Done when
- [ ] Readiness, not just liveness, reports healthy
- [ ] Recreating the container loses no data
- [ ] No secret appears anywhere in the repository
- [ ] The port is not reachable from outside the host
### Watch out
- Checking health against the liveness endpoint alone gives you a container marked healthy while the database it needs has not opened, so the dashboard answers and records nothing; the published guide distinguishes the endpoint that confirms the process is alive from the one that confirms the shared database and every open tenant database are ready (checked). Use the readiness endpoint, and treat a healthy container with no incoming events as an incident rather than a quiet week.
## M2 · A public URL and honest client addresses
TLS terminates at your proxy and the counts reflect visitors rather than the proxy.
### Steps
1. Terminate TLS in the proxy you already operate
Two certificate stores is one more than anyone maintains.
Files: `proxy/hitkeep.conf`
2. Set the public URL to the exact address, scheme included
A mismatch here shows up as sessions that will not stick.
Files: `.env`
3. Trust the proxy range and nothing else
Then verify the recorded address from a machine outside your network.
Files: `compose.yml`
```sh
curl -H 'X-Forwarded-For: 1.2.3.4' https://analytics.example.com/
```
### Done when
- [ ] An outside visit records the outside address
- [ ] A forged forwarded header changes nothing
- [ ] Sessions survive a reload on the public URL
- [ ] Certificate renewal has been exercised
### Watch out
- Leaving the trusted proxy setting empty records every visit as coming from the proxy, so every visitor looks like one visitor and the geography is your own data centre; setting it to accept any address instead lets anyone forge a client address by sending a header. Set it to the proxy's own range and check the recorded address from a second machine.
## M3 · A backup you have actually restored
A snapshot exists on another machine and has been brought back up at least once.
### Steps
1. Choose the interval and retention on purpose
The defaults are for a demonstration, not for a year of history.
Files: `compose.yml`, `.env`
2. Copy off the box with a checksum and a manifest
A backup nobody has checksummed is a file of unknown provenance.
Files: `backup.sh`, `backups/manifest.csv`
3. Restore onto a scratch stack and compare totals
This is the step everyone documents and nobody performs.
Files: `restore.sh`, `runbook.md`
### Done when
- [ ] An off-box copy exists with a verified checksum
- [ ] A scratch restore shows matching totals
- [ ] Off-box retention exceeds the container's own
- [ ] Losing the local backup volume loses nothing
### Watch out
- The built-in snapshots live in a volume on the same disk as the database, so they survive a bad upgrade and not a failed disk, a deleted volume or an accidental compose down with the volume flag; and a retention measured in hours means a corruption noticed on Tuesday has already aged out by Wednesday. Copy off the box on a schedule, keep more generations there than the container keeps locally, and record when each was last restored.
## M4 · Upgrades you can undo
A new image can be tried and reversed without losing a day of data.
### Steps
1. Snapshot, then record the digest, then pull
In that order, because the third step is the only reversible one.
Files: `update.sh`
2. Write the rollback and rehearse it
A rollback first attempted during an outage is a second outage.
Files: `rollback.sh`, `runbook.md`
3. Read the release notes before every upgrade
A one-way migration is always mentioned, and always by someone nobody read.
Files: `runbook.md`
### Done when
- [ ] A failed pre-upgrade snapshot stops the update
- [ ] The previous digest is recorded outside the container
- [ ] A rollback has been performed on the scratch stack
- [ ] Nothing pulls images automatically on this host
### Watch out
- Running an unattended pull of a moving tag on a schedule eventually applies a release that migrates the stored format in one direction, and since the previous image can no longer open the migrated files the only way back is the snapshot you did not take. Snapshot first, record the digest you are leaving, and never let an automatic pull touch this box.
## M5 · Knowing it is still collecting, and being able to leave (production only)
Silence is detected within a day, disk growth is bounded, and the data can walk out.
### Steps
1. Assert a synthetic event lands
A page returning two hundred proves the page returns two hundred.
Files: `checks/synthetic.sh`
2. Alert on collection stopping and on disk
The two failures that are silent until they are expensive.
Files: `checks/alerts.yml`, `runbook.md`
3. Export on a schedule to something portable
The exit is worth building while you are not in a hurry to leave.
Files: `export.sh`
### Done when
- [ ] A broken tracker raises an alert within the window
- [ ] Disk thresholds exist below the volume size
- [ ] A scheduled export opens without this application
- [ ] The runbook names the person on call
### Watch out
- Monitoring the dashboard with a plain page check keeps reporting green while a broken tracker tag, a blocked script or a proxy misconfiguration means nothing is being recorded, and the gap is discovered a month later when someone asks about last quarter. Send a synthetic event and assert it lands, so the check tests the thing you actually want.# Operations · HitKeep Cloud ## Backup The container snapshots on an interval into its own volume with a retention measured in hours, which is a starting point rather than a backup. The real backup is a scheduled copy to a second machine with a checksum, a manifest and more generations than the local window. ## Restore Stop the service, restore the snapshot into the data volume, start, and compare the site list and visitor totals against what you expect. Do this once on a scratch stack before you need it, and write the date in the runbook. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Readiness rather than liveness, a synthetic event asserted to land, disk usage on all three volumes with a threshold below the volume size, and the age of the newest off-box backup. ## Incident checklist The characteristic failure is silence: healthy container, working dashboard, no events, because a tag changed or the proxy stopped forwarding. The synthetic check is what turns that from a quarterly surprise into a same-day alert. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Readiness rather than liveness is confirmed as the health check - [ ] An outside visit is confirmed to record the outside address - [ ] A scratch restore from an off-box copy is confirmed to match - [ ] A rollback is confirmed rehearsed with the digest recorded - [ ] A deliberately broken tracker is confirmed to raise an alert ## Launch constraint Do not market omitted HitKeep Cloud capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Exactly the address people open, scheme included. A mismatch breaks sessions. HITKEEP_PUBLIC_URL=https://analytics.example.com # Required · secret. Session signing. Generated on the host, stored in your password manager, never in git. HITKEEP_JWT_SECRET=generated-locally-never-committed # Required. The proxy's network range only. Wider means forged client addresses. HITKEEP_TRUSTED_PROXIES=172.18.0.0/16 # Required. Snapshot interval in minutes. Set it on purpose rather than accepting the default. HITKEEP_BACKUP_INTERVAL=60 # Required. Local retention in hours. Short by design, which is why the off-box copy exists. HITKEEP_BACKUP_RETENTION=72 # Required. Pinned by digest. The update script changes this line and records the old one. HITKEEP_IMAGE=pascalebeier/hitkeep@sha256:...redacted # Required. Where the off-box copies land, with more generations than the container keeps. BACKUP_REMOTE_TARGET=s3://backups.example.com/hitkeep/
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay because analytics has to keep collecting while nobody is watching. The subscription turns upgrades, backups, email delivery, TLS, and routine incidents into someone else's problem without changing the product or export model.
xmanaged upgrades and monitoring
xencrypted regional backups
xmanaged email delivery
xvendor support and incident response
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
HitKeep Cloud pricing
pro$17/mo · monthly per team · $204/yr
free tierThe free plan includes 3 sites, 3 team members, and 60 days of history with no time limit.
verified 2026-08-11 · source ↗
Is HitKeep Cloud free?
The free plan includes 3 sites, 3 team members, and 60 days of history with no time limit. Paid is Pro at $17/mo (checked 2026-08-11).
Vibecode HitKeep Cloud
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal HitKeep Cloud replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does HitKeep Cloud cost?
HitKeep Cloud costs about $17/month (Pro, checked 2026-08-11), which is $204 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing HitKeep Cloud?
Honestly: managed upgrades and monitoring; encrypted regional backups; managed email delivery; vendor support and incident response. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to HitKeep Cloud?
Yes: HitKeep Self-Hosted (Exactly the same analytics product, except the upgrade window and backup drill belong to you.) The prompt is for when you want it exactly your way.