Running your own Progress Watch
The server is open source under the AGPL, and the hosted service runs the same image you would. Self-hosting costs one container and one volume, and the database stays small forever because progress is never written to it.
One file
docker-compose.yml is the whole install. It runs the published image, so there is nothing to clone and nothing to build. Copy it from the Docker page, which prints the current file, and start it:
That is the whole setup: the app on http://localhost:7979, with Redis and SQLite inside the same container and everything they keep on the named volume. Open it, create a space, and the Connect menu on that space hands you snippets with its UUID already filled in.
Configuring it
The file is yours from the moment you save it, and it is the only place anything is configured. Every setting is a line in an environment: block under the service, which the file leaves out until you need one:
environment: - DATABASE_URL=postgresql://progresswatch:secret@db.internal/progresswatch - VAPID_PUBLIC_KEY=BJ1w… - VAPID_PRIVATE_KEY=xK2p…
Environment variables is the full list, and everything on it works here. To publish somewhere other than 7979, change the left-hand side of 7979:3000 — the app inside the container is always on 3000.
curl http://localhost:7979/up
{"status":"ok","database":true,"redis":true,"worker":true}
What is running
Two processes and a file, all in one container. The app answers requests and runs the worker inside itself, since Sidekiq is embedded in Puma, and it starts Redis beside itself and stops it on the way out. Redis holds the current progress of every task and the job queue, and snapshots both to the volume; SQLite holds spaces and the shape of each task. Set REDIS_URL and the app uses that Redis instead and starts none of its own.
Updating it
Pull the new image and start it again:
docker compose pull docker compose up -d
Every running task stays where it was. Stopping the container makes Redis write a snapshot to the volume, and the new container loads it as it starts. Only a crash or a power cut skips that write, and then what comes back is the last periodic snapshot, minutes old when reports are frequent and up to an hour old when they are not: a task reads "Waiting for data…" or an older number until its next report.
Why 7979
Not 3000. This is a service you leave running for weeks while you work, and 3000 is where your own dev servers land — Rails, Vite, Next, Grafana and half the docker run lines in other guides. A background service should not hold the most contended port on the machine, least of all one whose URL you paste into a CI secret and then forget about.
Inside the container it is still 3000; only the published port moved, and that is the 7979:3000 line.
Losing the volume is recoverable
Spaces, task structure, a snapshot of Redis and the secret key are on it. If it goes, you create a new space and your reporting processes repopulate everything on their next request, and a new secret key costs nothing, because there are no accounts to sign anyone out of. Back it up if you would rather not redistribute a new space UUID to whatever reports into it, but there is nothing on it that cannot be rebuilt by running the jobs again.
SQLite or PostgreSQL
Both, from the same image and the same migrations, chosen by DATABASE_URL. SQLite is the default and is the right answer for one box: two tables, primary-key lookups, almost never written. Point it at postgresql://… when you are running several app containers, because they cannot share a file.
Behind a proxy
Set FORCE_SSL=true once something in front of the app terminates TLS. It is off by default so that an install reached over plain HTTP does not redirect you to a certificate you do not have.
Notifications
Set a VAPID key pair and a "Notify me" button appears on every space; leave the keys unset and the feature does not exist. There is nothing to register with Apple or Google. See notifications for the whole story, including what has to happen on a phone.
Sweeping empty spaces
Opening the site creates a space for you, so plenty get created and never used — including by anything that crawls the page. A space that has ever held a task is kept for good, however long nobody looks at it. Only ones that never held a single task are swept, and only once they are more than 30 days old.
Nothing runs it for you. Schedule it however you already schedule things:
# crontab, daily at 04:17 17 4 * * * cd /path/to/progresswatch && docker compose exec -T progresswatch bin/rails sweep_empty_spaces
Because nothing is on a timer, 30 days is a floor rather than a schedule: run it daily and empty spaces go the day they qualify, run it once a year and they sit until then. Skipping it is safe for your data — the sweep only ever takes rows that were never used — it just means the table grows with spaces nobody ever opened.
Your instance is not indexable
Every page of a self-hosted install is noindex, robots.txt disallows everything, and /sitemap.xml answers 404. There is no audience to reach from somebody's own box and nothing there that wants to be found in a search, and nothing has to be configured for it — it is what you get.