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 a Redis, 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, replace the placeholder secret with openssl rand -hex 64, and start it:
That is the whole setup: the app on http://localhost:7979, a Redis, and a SQLite file on a 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, so adding one is adding a line:
environment:
- SECRET_KEY_BASE=8f3c…
- 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. The app answers requests and runs the worker inside itself — Sidekiq is embedded in Puma, so a notification has no service of its own to be missing. Redis holds the current progress of every task and the job queue; SQLite holds spaces and the shape of each task.
Updating it
Name the service. A bare docker compose pull pulls Redis as well, and if a newer redis:8-alpine has been published, up -d replaces that container too — which throws away the progress of everything currently running.
docker compose pull progresswatch docker compose up -d progresswatch
Replacing only the app container leaves every running task exactly where it was, because the progress is in Redis and Redis was not touched. Titles and structure are on the volume and survive either way. Nothing survives docker compose down or a reboot of the host: Redis keeps nothing on disk, deliberately.
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 and task structure are on it, and nothing else. If it goes, you create a new space and your reporting processes repopulate everything on their next request. 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.