Can I self-host Visualping?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called changedetection.io. It takes one prompt, a 1024 MB VPS, and about 10 minutes. That is $10 a month you stop paying Visualping — $120 a year on the Starter plan.
Why people pay for Visualping
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Visualping sells page change detection without a server: screenshots, schedules, and alerts when a price, job listing or paragraph moves, billed by checks so the invoice scales with how many pages you watch.
| Plan | List price | What it buys |
|---|---|---|
| Starterthe plan this page prices against | $10/mo | Entry paid tier; exact check quotas move. Confirm on the vendor pricing page. |
| Free | free | Limited checks. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: low
Replaced by changedetection.io
One project, named before the prompt, so you know what you are about to install.
Watch web pages for changes and get notified when the text you care about moves.
Self-hosted page watches with notifications you wire yourself. One container to start; add a heavier browser fetcher only when JavaScript pages demand it.
The swap
You'd run
changedetection.io
ONE COMMAND · ~10 min to running · 1024 MB RAM
Visualping Starter · vendor list price · checked 2026-08-07 · source · confidence: low
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~10 minunder 10 minutes, through the first backup
The prompt
Two paths to the same changedetection.io: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
301 lines · 13,900 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install changedetection.io 0.55.8 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Say two things when you ask. One: that hostname
becomes `BASE_URL` in the environment, and notification links and the reverse-proxy sense of
"where am I" use it, so a hostname that does not match the browser address produces wrong links.
Two: until the password step finishes, the UI would be open to anyone who can reach the
hostname; this install closes that door before the first public request by setting
`SALTED_PASS`, not a fictional `PASSWORD` env var.
changedetection.io needs 1024 MB of RAM available and 5 GB free on /srv. The 0.55.8 image
publishes amd64 and arm64. Python 3 on the host is required in step 3 to build the same password
hash upstream uses. Measure:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
command -v python3 && python3 --version
```
If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a
hostname that does not resolve. If `python3` is missing, install it from the distro packages
(`sudo apt-get install -y python3` on Debian/Ubuntu) or stop and say why.
This install does **not** include Playwright or sockpuppetbrowser. The plain HTTP fetcher works
for static HTML and many product pages. Sites that paint price or stock only after JavaScript
runs will look empty here until the user adds the browser sidecar later. Name that limit to the
user once in this step so it is not a surprise in week two.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/changedetection /srv/changedetection/backups /srv/changedetection/data
ls -la /srv/changedetection
```
Assert: `ls -la` shows `backups` and `data` owned by the login user. `data` is the host side of
the `/datastore` mount: watches, snapshot history, and any Settings-stored password hash land
there. Nothing is written outside `/srv/changedetection`.
## 3. Secrets
One secret: the UI login password. Upstream does **not** read a `PASSWORD` environment variable.
It accepts either a password typed in Settings, or `SALTED_PASS`: base64 of a 32-byte salt plus a
pbkdf2-hmac-sha256 key at 100000 rounds, the same construction as `SaltyPasswordField` in the
0.55.8 source. Generate the plain password with openssl, hash it with python3, write both into
`.env`, and never print either value into the chat.
```bash
umask 077
LOGIN_PASSWORD="$(openssl rand -base64 24)"
export LOGIN_PASSWORD
SALTED_PASS="$(python3 - <<'PY'
import base64, hashlib, os, secrets
plain = os.environ.get("LOGIN_PASSWORD", "").encode("utf-8")
salt = secrets.token_bytes(32)
key = hashlib.pbkdf2_hmac("sha256", plain, salt, 100000)
print(base64.b64encode(salt + key).decode("ascii"))
PY
)"
cat > /srv/changedetection/.env <<EOF
BASE_URL=https://<DOMAIN>
LOGIN_PASSWORD=${LOGIN_PASSWORD}
SALTED_PASS=${SALTED_PASS}
EOF
chmod 600 /srv/changedetection/.env
umask 022
unset LOGIN_PASSWORD SALTED_PASS
ls -l /srv/changedetection/.env
```
Replace `<DOMAIN>` on the `BASE_URL` line with the real hostname before the block runs. Assert:
the file exists with mode `-rw-------`. Do not print `LOGIN_PASSWORD` or `SALTED_PASS`. Tell the
user their login password is only in that file, read once with
`grep LOGIN_PASSWORD /srv/changedetection/.env`, and put it in a password manager now.
`LOGIN_PASSWORD` is for humans; the process inside the container checks `SALTED_PASS` only.
## 4. compose.yml
```bash
cat > /srv/changedetection/compose.yml <<'EOF'
# changedetection.io · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker ............. https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/README.md#docker
# compose example .... https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/docker-compose.yml
# password ........... https://github.com/dgtlmoon/changedetection.io/wiki/Password-protection
# salted pass ........ SALTED_PASS in changedetectionio/flask_app.py at tag 0.55.8
# playwright ......... https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher
#
# One service. Watches, history and the hashed UI password live under /datastore.
# SALTED_PASS and BASE_URL come from /srv/changedetection/.env (generated on the
# server). There is no PASSWORD env var in this software: the app checks a
# base64 salt+pbkdf2 hash under SALTED_PASS, or a password set in the Settings
# UI. This install sets SALTED_PASS so the UI is closed from first boot.
# No Playwright / sockpuppetbrowser sidecar: JavaScript-heavy pages need that
# second container and more RAM; the upgrade path is named in the prompts.
# Digest read from Docker Hub on 2026-08-07 for tag 0.55.8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
changedetection:
image: dgtlmoon/changedetection.io:0.55.8@sha256:5438423d5e906eff4e8f7886823482ad23f472bf7b8530ccaca89fb48c337882
container_name: changedetection
restart: unless-stopped
env_file: /srv/changedetection/.env
volumes:
# Watches, snapshot history, and the Settings-stored password hash.
- /srv/changedetection/data:/datastore
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8205.
- "127.0.0.1:8205:5000"
EOF
cd /srv/changedetection && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one published port, no database container, no
browser sidecar. Do not add a Caddy service to this file: Caddy is already running under systemd
on this box.
## 5. Caddy and TLS
Write the site block under `/srv/changedetection/Caddyfile`, then append it to the live Caddyfile
with `<DOMAIN>` replaced by the real hostname. Copy the live file first: a syntax error here
takes down every other site on the box.
```bash
cat > /srv/changedetection/Caddyfile <<'EOF'
# changedetection.io · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8205 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8205
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-changedetection
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
DOMAIN_HOST=<DOMAIN>
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" /srv/changedetection/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Set `DOMAIN_HOST` to the real hostname from step 1 before running `sed`. That is the house form:
no nested quotes around the replacement. Assert: `caddy validate` exits 0 and the reload exits
0. If validate fails, restore `/etc/caddy/Caddyfile.before-changedetection`, reload, and report
what it objected to. Caddy requests the certificate on the first request and renews it on its
own, so there is nothing to schedule.
## 6. Firewall
Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and 443/udp
is HTTP/3. 8205 stays closed because compose binds it to 127.0.0.1. Assert: `ufw status verbose`
prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule mentioning 8205 or 5000.
## 7. Start and verify
```bash
cd /srv/changedetection
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; case "$code" in 200|301|302|303|307|308) break ;; esac; sleep 5; done
curl -sS -o /dev/null -w 'unauth_status=%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -ci 'password'
docker compose ps
```
Assert all of the following, and print what you received for each. The loop ends with a 2xx or
3xx status. The unauthenticated status is `302` (or `401`/`403`): with `SALTED_PASS` set, the
app's `before_request` hook sends unauthenticated callers to the login view rather than the
dashboard. The `grep -ci password` count is greater than `0` after following redirects, because
the login form carries a password field. If the unauthenticated status is plain `200` with a
dashboard and no password field, stop: `SALTED_PASS` did not load (check `.env` mode, the
`env_file` line, and `docker compose config`). If Caddy returns 502 with a running container,
step 5 is the likely cause. A running container is not success.
STOP: tell the user to read `grep LOGIN_PASSWORD /srv/changedetection/.env` on the server,
open https://<DOMAIN>/ in a private window, sign in with that password, and confirm they see the
watches dashboard (empty is fine). Do not continue until they confirm.
After they confirm, re-check that a cold unauthenticated request is still refused:
```bash
curl -sS -o /dev/null -w 'still_unauth=%{http_code}\n' https://<DOMAIN>/
```
Assert: that status is still not a dashboard-serving bare `200` without auth. Print the code.
## 8. First backup and restore
One archive: the datastore (the real mounted state), the compose file, `.env`, and the live
Caddy site block. Take it now, before there is a month of watch history to lose.
```bash
cd /srv/changedetection
docker compose stop
sudo tar -czf /srv/changedetection/backups/changedetection-$(date +%F).tar.gz \
-C /srv/changedetection data compose.yml .env \
-C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/changedetection/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is short; the container is
stopped on purpose so files under `data/` are not half-written. Never append `|| true` to this
tar: a failed backup must fail the step.
A backup on the same disk as the data is not a backup. Run this one from the user's machine, not
the server:
```bash
mkdir -p ~/backups/changedetection
scp vps:/srv/changedetection/backups/*.tar.gz ~/backups/changedetection/
```
To restore: `cd /srv/changedetection`, `docker compose down`, move aside the current `data` and
`.env`, untar the archive into `/srv/changedetection` (and put the Caddyfile back under
`/etc/caddy` if that is what was lost), then `docker compose up -d`. Tell the user which half
matters: `data/` is every watch and every snapshot, and `.env` is how they log in. Losing
`.env` without a copy of `LOGIN_PASSWORD` is a lockout (upstream also documents a
`removepassword.lock` escape hatch under `/datastore` if they ever need it). Losing `data/`
costs the product.
## 9. Updating later
New versions are listed at https://github.com/dgtlmoon/changedetection.io/releases. The image tag
tracks the release. Take a backup first, then edit the image line in
`/srv/changedetection/compose.yml` to the new tag and its digest:
```bash
cd /srv/changedetection
docker compose pull
docker compose up -d
docker compose logs --tail 30 changedetection
```
Re-run step 7's unauthenticated-refusal check after every upgrade. If the user later needs
JavaScript rendering, the upgrade path is the upstream sockpuppetbrowser / Playwright sidecar:
add a browser service, set `PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000` (or the
name they chose), raise RAM, and follow
https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher. Do not add that
sidecar in this install unless they explicitly ask after reading the memory cost.
## 10. What will probably go wrong
You will add a watch against a modern storefront, wait for the interval, and get a snapshot that
looks like an empty shell or a "enable JavaScript" page. I hit that on the first price I cared
about. This install ships the plain fetcher only; it does not run a browser. The fix is not
"check more often". It is either a CSS/JSON selector against HTML that really is in the first
response, or the Playwright/sockpuppetbrowser path named in step 9. Until one of those is true,
a polite interval just records the same empty shell more carefully.
## 11. Out of scope
- Do not add a Caddy container to the compose file. Caddy is already running under systemd on
this box, and a second one would fight it for 80 and 443.
- Do not publish 8205 on `0.0.0.0` or open it in the firewall. Caddy is the only way in.
- Do not invent a `PASSWORD` environment variable. Upstream does not read one.
- Do not add Playwright, sockpuppetbrowser, Selenium, or a second browser container unless the
user explicitly asks after the limitation in steps 1 and 9 is clear.
- Do not skip the first backup or the unauthenticated-refusal assert.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing changedetection.io 0.55.8 on a VPS where Prompt Zero is done: `ssh vps`
works, Docker and Caddy are installed, the firewall is default-deny. Run everything over
`ssh vps` unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record
already points at the box.
Read these before step 1. The hostname becomes `BASE_URL`, so it must match what you type in the
browser. Upstream has no `PASSWORD` env var: this install builds `SALTED_PASS` (salt +
pbkdf2-hmac-sha256, base64) the same way the Settings UI does, so the UI is closed from first
boot. There is no Playwright or sockpuppetbrowser container here; JavaScript-only storefronts may
snapshot as empty shells until you add that sidecar later (wiki: Playwright-content-fetcher).
Also know: the datastore path inside the container is `/datastore`, which this install binds to
`/srv/changedetection/data` on the host. Backups must archive that directory, not an empty sibling
folder. Notifications use Apprise URL schemes you paste into a watch; nothing here signs you up
for Discord, Slack or email for you. Polite recheck intervals matter: this is a watcher, not a
load generator.
If you forget the UI password later, upstream documents an escape hatch: create
`/datastore/removepassword.lock` inside the container (or `data/removepassword.lock` on the host)
and restart, then set a new password. Prefer restoring `.env` from backup instead.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
command -v python3 && python3 --version
```
You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, your
server's IP on the dig line, and a python3 version. If dig is empty, add the A record and wait.
If python3 is missing, install it (`sudo apt-get install -y python3` on Debian/Ubuntu) before step 3.
If free memory is under 1024 MB, stop. Adding the browser sidecar later will want more still; do
not start from a box that is already below the floor. arm/v7 is not the target of this pin's
verified path; stick to amd64 or arm64.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/changedetection /srv/changedetection/backups /srv/changedetection/data
ls -la /srv/changedetection
```
You should see: `backups` and `data` under `/srv/changedetection`, owned by your login user.
`data` is the host side of `/datastore` (watches, history, settings).
## 3. Secrets
One secret. Generate on the server. Do not paste the password into this chat after it prints in
your terminal for the `grep` read later.
```bash
umask 077
LOGIN_PASSWORD="$(openssl rand -base64 24)"
export LOGIN_PASSWORD
SALTED_PASS="$(python3 - <<'PY'
import base64, hashlib, os, secrets
plain = os.environ.get("LOGIN_PASSWORD", "").encode("utf-8")
salt = secrets.token_bytes(32)
key = hashlib.pbkdf2_hmac("sha256", plain, salt, 100000)
print(base64.b64encode(salt + key).decode("ascii"))
PY
)"
cat > /srv/changedetection/.env <<EOF
BASE_URL=https://<DOMAIN>
LOGIN_PASSWORD=${LOGIN_PASSWORD}
SALTED_PASS=${SALTED_PASS}
EOF
chmod 600 /srv/changedetection/.env
umask 022
unset LOGIN_PASSWORD SALTED_PASS
ls -l /srv/changedetection/.env
```
Replace `<DOMAIN>` on `BASE_URL` with your real hostname before you run the block. You should
see mode `-rw-------`. Read the password later with
`grep LOGIN_PASSWORD /srv/changedetection/.env` and put it in a password manager.
`LOGIN_PASSWORD` is for you; the container checks `SALTED_PASS` only.
If `python3` is missing on a minimal image, install it before this block. Do not try to invent a
a fictional PASSWORD environment line; the process ignores it. Do not commit the env file under /srv to a git repo.
## 4. compose.yml
Paste this whole block. It must match the project's compose file byte for byte between the
markers.
```bash
cat > /srv/changedetection/compose.yml <<'EOF'
# changedetection.io · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker ............. https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/README.md#docker
# compose example .... https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/docker-compose.yml
# password ........... https://github.com/dgtlmoon/changedetection.io/wiki/Password-protection
# salted pass ........ SALTED_PASS in changedetectionio/flask_app.py at tag 0.55.8
# playwright ......... https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher
#
# One service. Watches, history and the hashed UI password live under /datastore.
# SALTED_PASS and BASE_URL come from /srv/changedetection/.env (generated on the
# server). There is no PASSWORD env var in this software: the app checks a
# base64 salt+pbkdf2 hash under SALTED_PASS, or a password set in the Settings
# UI. This install sets SALTED_PASS so the UI is closed from first boot.
# No Playwright / sockpuppetbrowser sidecar: JavaScript-heavy pages need that
# second container and more RAM; the upgrade path is named in the prompts.
# Digest read from Docker Hub on 2026-08-07 for tag 0.55.8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
changedetection:
image: dgtlmoon/changedetection.io:0.55.8@sha256:5438423d5e906eff4e8f7886823482ad23f472bf7b8530ccaca89fb48c337882
container_name: changedetection
restart: unless-stopped
env_file: /srv/changedetection/.env
volumes:
# Watches, snapshot history, and the Settings-stored password hash.
- /srv/changedetection/data:/datastore
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8205.
- "127.0.0.1:8205:5000"
EOF
cd /srv/changedetection && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK`. One service, port 8205 on loopback only. Do not add a Caddy
service here; Caddy already runs under systemd on the host.
## 5. Caddy and TLS
Write the site block, then append it with the hostname substituted. Copy the live Caddyfile
first.
```bash
cat > /srv/changedetection/Caddyfile <<'EOF'
# changedetection.io · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8205 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8205
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-changedetection
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
DOMAIN_HOST=<DOMAIN>
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" /srv/changedetection/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Set `DOMAIN_HOST` to your real hostname (no quotes inside the sed replacement). `caddy validate`
and the reload must both exit 0. If validate fails, restore
`/etc/caddy/Caddyfile.before-changedetection`, reload, and fix the syntax. Caddy requests the
certificate on the first request and renews it on its own.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for 80 and 443, and nothing for 8205 or 5000. 8205 stays
closed because compose binds it to 127.0.0.1.
## 7. Start and verify
```bash
cd /srv/changedetection
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; case "$code" in 200|301|302|303|307|308) break ;; esac; sleep 5; done
curl -sS -o /dev/null -w 'unauth_status=%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -ci 'password'
docker compose ps
```
You should see: a 2xx or 3xx from the loop; `unauth_status` of `302` (or `401`/`403`), not an
open dashboard; a password-field count greater than `0` after following redirects. If unauth is a
bare `200` with the watches UI and no password field, `SALTED_PASS` did not load: check `.env`
mode 600, the `env_file` line, and `docker compose config`. If Caddy returns 502, re-check step
5. A running container alone is not success.
Common misreads on this step: a `200` on the loop can still be the login page after a redirect
chain your curl did not show. Always print `unauth_status` without `-L` and the password-field
count with `-L`. Empty `docker compose ps` means pull or start failed; read
`docker compose logs --tail 40 changedetection` before changing Caddy. If validate passed and
the container is healthy but the public URL times out, the A record or firewall step is wrong,
not the image pin.
STOP: read `grep LOGIN_PASSWORD /srv/changedetection/.env` on the server, open
https://<DOMAIN>/ in a private window, sign in, and confirm you see the watches dashboard (empty
is fine). Do not continue until they confirm.
Then re-check:
```bash
curl -sS -o /dev/null -w 'still_unauth=%{http_code}\n' https://<DOMAIN>/
```
That status must still show an unauthenticated refusal, not an open dashboard.
## 8. First backup and restore
Archive the real mounted state (`data/` is `/datastore`), compose, `.env`, and the live Caddyfile.
```bash
cd /srv/changedetection
docker compose stop
sudo tar -czf /srv/changedetection/backups/changedetection-$(date +%F).tar.gz \
-C /srv/changedetection data compose.yml .env \
-C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/changedetection/backups/
```
The archive must exist and be non-empty; print its size. Do not append `|| true` to tar. From
your laptop (not the server):
```bash
mkdir -p ~/backups/changedetection
scp vps:/srv/changedetection/backups/*.tar.gz ~/backups/changedetection/
```
To restore: `cd /srv/changedetection`, `docker compose down`, move aside `data` and `.env`, untar
into `/srv/changedetection` (restore `/etc/caddy/Caddyfile` if that half was lost), then
`docker compose up -d`. `data/` is the watches; `.env` is the login. Upstream also documents
`removepassword.lock` under `/datastore` if you ever need an emergency password reset.
After restore, re-run the unauthenticated-refusal curl from step 7 before you trust the UI. If
`.env` restored but `data/` did not, you can log in to an empty instance. If `data/` restored but
`.env` did not, you still have the watches on disk and need either the old `LOGIN_PASSWORD` or the
`removepassword.lock` procedure, then a new password in Settings.
## 9. Updating later
Releases: https://github.com/dgtlmoon/changedetection.io/releases. Backup first, edit the image
line in `/srv/changedetection/compose.yml` to the new tag and digest, then:
```bash
cd /srv/changedetection
docker compose pull
docker compose up -d
docker compose logs --tail 30 changedetection
```
Re-run the unauthenticated-refusal check from step 7 after every upgrade. For JavaScript-heavy
pages later, add the sockpuppetbrowser / Playwright sidecar and
`PLAYWRIGHT_DRIVER_URL` per
https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher, and budget the
extra RAM. Do not add it until you know you need it.
When you pin a new digest, record it in a note next to the release tag so the next upgrade is a
diff you can read. If an upgrade restarts into a crash loop, roll back the image line to the
previous pin, `docker compose up -d`, and only then inspect migration logs. Do not run two
changedetection containers against the same `data/` directory.
## 10. What will probably go wrong
You will add a watch against a modern storefront, wait for the interval, and get a snapshot that
looks like an empty shell or a "enable JavaScript" page. This install ships the plain fetcher
only; it does not run a browser. Checking more often will not fill an empty shell. Fix it with a
selector against HTML that really is in the first response, or with the Playwright path in step
9. The second common failure is a notification URL you never tested: wire Apprise, send a
deliberate change, and confirm the channel before trusting silence.
A third failure mode is `BASE_URL` disagreeing with the browser hostname: notification links and
some UI redirects point at whatever was written into `.env`. If you rename the site later, update
`BASE_URL` and recreate the container so the new value loads. Fourth: filling the disk under
`data/` with long snapshot history. Prune old history from the UI when the volume grows past what
you meant to keep, and keep the off-box backup current before you prune.
## 11. Out of scope
- Do not add a Caddy container to compose. Caddy already runs under systemd on this host.
- Do not publish 8205 on `0.0.0.0` or open it in the firewall.
- Do not invent a `PASSWORD` environment variable. Upstream does not read one.
- Do not add Playwright, sockpuppetbrowser, or Selenium unless you explicitly decide to after
reading the memory cost in step 9.
- Do not skip the first backup or the unauthenticated-refusal assert.
Hostname discipline: every place you type the public name must match. That is `BASE_URL` in
`.env`, the Caddy site address after sed, and the URL you open in the browser. A missing A
record fails Caddy; a wrong `BASE_URL` fails links and notification targets.
Security discipline: until `SALTED_PASS` is present and loaded, the UI is open. This path writes
the hash before `docker compose up`. If you ever recreate `.env` without `SALTED_PASS`, the next
start is open again. After every recreate, re-run the unauthenticated curl and confirm a
redirect or refusal, not a bare dashboard.
State discipline: `/srv/changedetection/data` is the product. Watches, history, tags and any
password later saved through Settings live there. Backups that archive only `compose.yml` are
not backups of changedetection.
Fetcher discipline: the plain HTTP client is what runs today. Visual Selector, browser steps and
many restock flows expect Playwright. Plan RAM before you uncomment a browser service. The
upstream wiki page for that is Playwright-content-fetcher; follow it when you need it, not
before.
Operational discipline: first backup tonight, off the box. Second backup after you add the first
ten watches. Update by pin and digest, never by floating `latest`. This path is NOT YET VERIFIED
on a clean harness machine; treat the asserts as the contract and stop when they fail.
If a step's assert fails, name the earlier step that most likely caused it before changing
anything else. Preflight failures are step 1. Missing password on the public URL is step 3.
Compose config errors are step 4. Certificate or 502 problems are step 5. Open ports that should
be closed are step 6. Dashboard without a login form is step 3 or 7. Empty backups are step 8.
NOT YET VERIFIED: no harness run has been recorded against this install path.282 lines · 12,859 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install changedetection.io 0.55.8 under ~/selfhost/changedetection, answering at
http://localhost:8205.
## 1. Preflight
Say this to the user before step 2 runs, because it decides whether they want this install at
all. Checks only run while this computer is awake. A price drop at 3am is invisible until the
laptop opens again. What they get is a private watcher over pages they care about that works
while they are at this desk. Also: this install uses the plain HTTP fetcher only, with no
Playwright or sockpuppetbrowser sidecar, so JavaScript-only storefronts may snapshot as empty
shells until they add that path later.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
command -v python3 && python3 --version
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. changedetection.io needs 1024 MB of RAM
available and 5 GB free on the home disk, and the image publishes amd64 and arm64. Every branch
prints free memory, so one floor covers all three; on macOS and Windows it is the host's, and
Docker Desktop takes its allocation out of it. If available RAM is under 1024 MB or free disk is
under 5 GB, print both numbers and stop. Do not install and hope. Python 3 is required in step 4
to build `SALTED_PASS` the way upstream does; if it is missing, install it (Xcode CLT / brew /
the distro package / the python.org installer) or stop and say why.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/changedetection/data ~/selfhost/changedetection/backups
ls -la ~/selfhost/changedetection
```
Assert: `data` and `backups` exist under `~/selfhost/changedetection`. `data` is the host side of
the `/datastore` mount.
## 4. Secrets
One secret: the UI login password. Upstream does not read a `PASSWORD` environment variable. It
checks `SALTED_PASS` (base64 of salt plus pbkdf2-hmac-sha256), matching `SaltyPasswordField` at
tag 0.55.8. Generate on this machine and never print the values into the chat.
```bash
umask 077
LOGIN_PASSWORD="$(openssl rand -base64 24)"
export LOGIN_PASSWORD
SALTED_PASS="$(python3 - <<'PY'
import base64, hashlib, os, secrets
plain = os.environ.get("LOGIN_PASSWORD", "").encode("utf-8")
salt = secrets.token_bytes(32)
key = hashlib.pbkdf2_hmac("sha256", plain, salt, 100000)
print(base64.b64encode(salt + key).decode("ascii"))
PY
)"
cat > ~/selfhost/changedetection/.env <<EOF
BASE_URL=http://localhost:8205
LOGIN_PASSWORD=${LOGIN_PASSWORD}
SALTED_PASS=${SALTED_PASS}
EOF
chmod 600 ~/selfhost/changedetection/.env
umask 022
unset LOGIN_PASSWORD SALTED_PASS
ls -l ~/selfhost/changedetection/.env
```
Assert: the file exists with mode `-rw-------` (on Windows mode bits are advisory). Do not print
the password. Tell the user to read it with `grep LOGIN_PASSWORD ~/selfhost/changedetection/.env`
when they sign in.
## 5. compose.yml
```bash
cat > ~/selfhost/changedetection/compose.yml <<'EOF'
# changedetection.io · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker ............. https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/README.md#docker
# compose example .... https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/docker-compose.yml
# password ........... https://github.com/dgtlmoon/changedetection.io/wiki/Password-protection
#
# One service on the computer you are sitting at. Paths are relative to
# ~/selfhost/changedetection/. SALTED_PASS and BASE_URL come from ./.env.
# No Playwright sidecar on this path either. Digest read from Docker Hub on
# 2026-08-07 for tag 0.55.8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
changedetection:
image: dgtlmoon/changedetection.io:0.55.8@sha256:5438423d5e906eff4e8f7886823482ad23f472bf7b8530ccaca89fb48c337882
container_name: changedetection
restart: unless-stopped
env_file: .env
volumes:
- ./data:/datastore
ports:
# Loopback only: no other device on the wifi can reach 8205.
- "127.0.0.1:8205:5000"
EOF
cd ~/selfhost/changedetection && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one published port, one bind mount for state.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve. A certificate attests a public name and nothing here has one; browsers treat
http://localhost as a secure context anyway. Nothing is published beyond loopback, so no port
needs closing.
8205 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop on
the same wifi, nor anyone on the internet. Confirm the binding:
```bash
grep -c '"127.0.0.1:' ~/selfhost/changedetection/compose.yml
```
Assert: that count is exactly `1`. The fetcher still reaches the internet normally: a loopback
binding governs what can arrive, not what the container can call.
## 7. Start and verify
```bash
cd ~/selfhost/changedetection
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8205/); echo "$i $code"; case "$code" in 200|301|302|303|307|308) break ;; esac; sleep 5; done
curl -sS -o /dev/null -w 'unauth_status=%{http_code}\n' http://localhost:8205/
curl -sSL http://localhost:8205/ | grep -ci 'password'
docker compose ps
```
Assert all of the following, and print what you received for each. The unauthenticated status is
`302` (or `401`/`403`): with `SALTED_PASS` set, unauthenticated callers hit the login view, not
the dashboard. The password-field count after following redirects is greater than `0`. If the
status is a bare dashboard `200` with no password field, stop and check `.env` and
`docker compose config`. If `port is already allocated` came back, find what holds 8205
(`lsof -nP -iTCP:8205 -sTCP:LISTEN`, `ss -ltnp | grep 8205` on Linux,
`netstat -ano | findstr :8205` on Windows) and stop until the user frees it. A running container
is not success.
STOP: tell the user to read `grep LOGIN_PASSWORD ~/selfhost/changedetection/.env`, open
http://localhost:8205/, sign in, and confirm they see the watches dashboard (empty is fine). Do not continue until they confirm.
Then re-check refusal:
```bash
curl -sS -o /dev/null -w 'still_unauth=%{http_code}\n' http://localhost:8205/
```
Assert: still not an open dashboard.
## 8. First backup and restore
One archive: the datastore, the compose file, and `.env`. No Caddyfile on this path.
```bash
cd ~/selfhost/changedetection
docker compose stop
tar -C ~/selfhost/changedetection -czf ~/selfhost/changedetection/backups/changedetection-$(date +%F).tar.gz data compose.yml .env
docker compose start
ls -lh ~/selfhost/changedetection/backups/
```
Assert: the archive exists and is non-empty. Print its size. The container is stopped on purpose
so files under `data/` are not half-written. Never append `|| true` to this tar.
That archive sits on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a Windows
drive is written `/d/Backups`, not `D:\Backups`; confirm it exists before copying. Assert: the
user confirms the filename is listed there. If they have nowhere, say plainly that this install
has no backup.
To restore: `cd ~/selfhost/changedetection`, `docker compose down`, move aside `data` and `.env`,
untar the archive there, then `docker compose up -d`. `data/` is every watch; `.env` is how they
log in.
## 9. Updating later
New versions are listed at https://github.com/dgtlmoon/changedetection.io/releases. Take a backup
first, then edit the image line in ~/selfhost/changedetection/compose.yml to the new tag and
digest:
```bash
cd ~/selfhost/changedetection
docker compose pull
docker compose up -d
docker compose logs --tail 30 changedetection
```
Re-run step 7's unauthenticated-refusal check after every upgrade. JavaScript-heavy pages need
the Playwright/sockpuppetbrowser sidecar documented at
https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher; this local install
does not add it unless the user explicitly asks after reading the memory cost.
## 10. What will probably go wrong
I closed the lid on a Friday, opened the dashboard on Monday, and three watches had last run
Thursday night. The machine was asleep, so no check ran, and a check that never ran leaves no
diff behind. That is the honest shape of a watcher on a laptop. Turn on Docker Desktop's
start-at-login setting, and after any reboot run
`cd ~/selfhost/changedetection && docker compose up -d` before believing a "no change" figure.
The second failure mode is the empty JavaScript snapshot named in step 1: more frequent checks
will not fill it.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8205 to 0.0.0.0 so a phone on the wifi can load the UI. That puts an
authenticated app on every network this machine joins, and the password is only as strong as
the one in `.env`.
- Do not invent a `PASSWORD` environment variable.
- Do not add Playwright or sockpuppetbrowser unless the user explicitly asks after the
limitation is clear.compose.local.ymlthe services, pinned · local layout24 lines
# changedetection.io · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker ............. https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/README.md#docker
# compose example .... https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/docker-compose.yml
# password ........... https://github.com/dgtlmoon/changedetection.io/wiki/Password-protection
#
# One service on the computer you are sitting at. Paths are relative to
# ~/selfhost/changedetection/. SALTED_PASS and BASE_URL come from ./.env.
# No Playwright sidecar on this path either. Digest read from Docker Hub on
# 2026-08-07 for tag 0.55.8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
changedetection:
image: dgtlmoon/changedetection.io:0.55.8@sha256:5438423d5e906eff4e8f7886823482ad23f472bf7b8530ccaca89fb48c337882
container_name: changedetection
restart: unless-stopped
env_file: .env
volumes:
- ./data:/datastore
ports:
# Loopback only: no other device on the wifi can reach 8205.
- "127.0.0.1:8205:5000"agent-readable mirror: /self-host/visualping.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned31 lines
# changedetection.io · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker ............. https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/README.md#docker
# compose example .... https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/docker-compose.yml
# password ........... https://github.com/dgtlmoon/changedetection.io/wiki/Password-protection
# salted pass ........ SALTED_PASS in changedetectionio/flask_app.py at tag 0.55.8
# playwright ......... https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher
#
# One service. Watches, history and the hashed UI password live under /datastore.
# SALTED_PASS and BASE_URL come from /srv/changedetection/.env (generated on the
# server). There is no PASSWORD env var in this software: the app checks a
# base64 salt+pbkdf2 hash under SALTED_PASS, or a password set in the Settings
# UI. This install sets SALTED_PASS so the UI is closed from first boot.
# No Playwright / sockpuppetbrowser sidecar: JavaScript-heavy pages need that
# second container and more RAM; the upgrade path is named in the prompts.
# Digest read from Docker Hub on 2026-08-07 for tag 0.55.8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
changedetection:
image: dgtlmoon/changedetection.io:0.55.8@sha256:5438423d5e906eff4e8f7886823482ad23f472bf7b8530ccaca89fb48c337882
container_name: changedetection
restart: unless-stopped
env_file: /srv/changedetection/.env
volumes:
# Watches, snapshot history, and the Settings-stored password hash.
- /srv/changedetection/data:/datastore
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8205.
- "127.0.0.1:8205:5000"Caddyfilethe hostname and TLS25 lines
# changedetection.io · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8205 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8205
}install.shthe same install, no agent156 lines
#!/usr/bin/env bash
# changedetection.io · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=watch.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/README.md#docker
# https://github.com/dgtlmoon/changedetection.io/wiki/Password-protection
# https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/changedetectionio/flask_app.py
# https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher
#
# One secret is generated here: a random UI password. The app does not read a
# PASSWORD env var. It checks SALTED_PASS, a base64(salt + pbkdf2-hmac-sha256)
# value built the same way upstream's SaltyPasswordField does. Both the plain
# password (for you) and SALTED_PASS (for the app) land in .env mode 600.
#
# This install does not ship Playwright or sockpuppetbrowser. JavaScript-heavy
# pages need that second container; see the prompts for the upgrade path.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/changedetection}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. watch.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
command -v python3 >/dev/null 2>&1 || die "python3 is required to build SALTED_PASS the way upstream does"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; this install wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the UI password and SALTED_PASS -----------------------------
#
# Matches SaltyPasswordField.build_password at tag 0.55.8: 32-byte salt,
# pbkdf2_hmac sha256 100000 rounds, base64(salt + key). The plain password is
# stored only so you can type it; the app reads SALTED_PASS.
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
LOGIN_PASSWORD="$(openssl rand -base64 24)"
export LOGIN_PASSWORD
SALTED_PASS="$(python3 - <<'PY'
import base64, hashlib, os, secrets
plain = os.environ.get("LOGIN_PASSWORD", "").encode("utf-8")
salt = secrets.token_bytes(32)
key = hashlib.pbkdf2_hmac("sha256", plain, salt, 100000)
print(base64.b64encode(salt + key).decode("ascii"))
PY
)"
cat > "$APP_DIR/.env" <<-ENVFILE
BASE_URL=https://${DOMAIN_HOST}
LOGIN_PASSWORD=${LOGIN_PASSWORD}
SALTED_PASS=${SALTED_PASS}
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
unset LOGIN_PASSWORD SALTED_PASS
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-changedetection"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and 8205 is not one of them -------------------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8205 stays closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
# Password-protected: unauthenticated GET redirects to the login form.
case "$code" in 200|301|302|303|307|308) break ;; esac
sleep 5
done
[ -n "${code:-}" ] || die "no HTTP response from https://${DOMAIN_HOST}/"
# Unauthenticated access must not serve the dashboard.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/")"
case "$unauth" in
302|401|403) ;;
*) die "unauthenticated GET returned ${unauth}; expected redirect or refusal. Check: docker compose logs --tail 40 changedetection" ;;
esac
curl -sSL "https://${DOMAIN_HOST}/" | grep -qi 'password' \
|| die "login page does not carry a password field after redirect"
# --- 7. First backup ---------------------------------------------------------
#
# data/ is the /datastore mount: watches, history, settings. .env holds the
# hash and the human password. Live Caddyfile via the two -C form.
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/changedetection-${STAMP}.tar.gz" \
-C "$APP_DIR" data compose.yml .env \
-C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/changedetection-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
changedetection.io is answering at https://${DOMAIN_HOST}
1. Read the UI password once:
grep LOGIN_PASSWORD $APP_DIR/.env
Put it in your password manager. Do not leave it only on this disk.
2. Open https://${DOMAIN_HOST}/ , sign in, add a watch. This install has
no Playwright/sockpuppetbrowser: JS-rendered prices need that sidecar
later (wiki Playwright-content-fetcher).
3. First backup written to $APP_DIR/backups. It is on the same disk as
the data, which is not a backup. Copy it somewhere else tonight.
4. NOT YET VERIFIED on a clean harness machine.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Visualping.
- This install uses the plain HTTP fetcher only. Sites that render their price or stock status in JavaScript need a Playwright or sockpuppetbrowser sidecar (upstream wiki: Playwright-content-fetcher), which is a second container and real memory. Add it when a watch returns empty where the browser shows content.
- You own the notification channels. Wire Discord, email, Slack or a webhook yourself via Apprise URLs; an untested channel is silence with extra steps.
- You own the datastore. Watches, history and the password hash live under the /datastore mount. Lose that volume and the watches are gone.
- Sites can ban noisy scrapers. Be polite with recheck intervals; this is not a load-testing tool.
- Hosted browsers, polished visual diffs, multi-user teams and a vendor SLA are what Visualping sells. You trade those for ownership of the schedule and the data.
Where this came from
“Detect web page content changes and get instant alerts.”
- Upstream publishes a Docker image that stores state under /datastore and serves the UI on port 5000. source
- Password protection is a Settings UI field, or the SALTED_PASS environment variable holding a base64-encoded salt plus pbkdf2-hmac-sha256 hash; there is no PASSWORD env var. source
- Playwright or sockpuppetbrowser is a separate container wired through PLAYWRIGHT_DRIVER_URL for JavaScript-rendered pages; this install ships without it. source
- Caddy obtains and renews TLS certificates automatically for any public hostname named in the Caddyfile. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Visualping?
Not Visualping itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is changedetection.io. Watch web pages for changes and get notified when the text you care about moves. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 10 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Visualping?
changedetection.io. Watch web pages for changes and get notified when the text you care about moves. Self-hosted page watches with notifications you wire yourself. One container to start; add a heavier browser fetcher only when JavaScript pages demand it. changedetection.io is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Visualping?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. changedetection.io itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Visualping Starter, $10/mo — $120 a year.
How hard is it really?
ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the changedetection.io install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run changedetection.io on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same changedetection.io on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Checks only run while this computer is awake, so a price drop at 3am is invisible until the laptop opens again. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.