What the stack consists of
The README gives two Docker routes: clone the repository and run docker compose up -d, or start dgtlmoon/changedetection.io with a single docker run. The same image is also published at ghcr.io/dgtlmoon/changedetection.io, which is the name the Compose file uses. The Dockerfile exposes 5000 and starts the app with -d /datastore; the Compose file publishes it as 127.0.0.1:5000:5000, so by default only the host can reach it. The README describes :latest as the newest stable release and :dev as the master branch.
| Part | Job | Where it is documented |
|---|---|---|
changedetection container | Web UI on 5000, fetch workers, notifications | README, Dockerfile, docker-compose.yml |
browser-sockpuppet-chrome container | Chrome behind the Playwright protocol, for pages built by JavaScript | docker-compose.yml (commented out until you enable it) |
Volume mounted at /datastore | Settings, watches, their history, the session secret | Dockerfile; backup code in blueprint/backups |
| Reverse proxy | HTTPS on 80 and 443 | Wiki page on running behind a reverse proxy |
The README calls sockpuppetbrowser the recommended way to fetch with Chrome. A Selenium container also sits in the file, but its own comments mark it deprecated: no full-page screenshots, so no Visual Selector, and no status codes. The Visual Selector and Browser Steps both need the Playwright fetcher.
Sizing the machine
Neither the README nor the wiki publishes CPU or memory minimums, so size from what drives the load. The plain fetcher makes an HTTP request per check. Chrome renders the whole page, and the Compose file caps it at MAX_CONCURRENT_CHROME_PROCESSES=10. The same file sets FETCH_WORKERS=10 as the default number of parallel fetchers and warns that raising SCREENSHOT_MAX_HEIGHT above its 20000 px default raises memory use. History is stored per watch in folders inside the volume, so disk use follows the number of watches and how much changes; that last point is our reading of the backup code, not a published figure.
- A monitor using the plain fetcher is one service, the job the dedicated server range gives its Moonshot cartridges: one small server per slot, one service per machine.
- Many JavaScript pages through Chrome is heavier. A single-socket rack server, or a dual-socket one if the box also carries other services, gives it room, and both come with a full out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC). Cartridges other than the m510 and m710x have power control only.
- Leave X-Gene out: that ARM cartridge boots only Ubuntu 16.04 and 18.04, older than the current Ubuntu and Docker Engine this build assumes.
- Delivery of a racked machine: about 30 minutes, counted from payment approval; built-to-order lines take 4 to 24 hours. Every line is the whole machine, single tenant, with root and an unmetered port.
Firewall and Compose file
Start from Ubuntu with Docker Engine 28 or later and the Compose v2 plugin. Run sudo -v first, so a password prompt cannot swallow a pasted line. ufw gets IPv6 turned on before its first rule and is enabled with SSH as the only open port, before any container starts. The Compose file is the official one with the sockpuppetbrowser service, its depends_on and PLAYWRIGHT_DRIVER_URL uncommented; the Docker Hub image name, the pinned tag (0.60.9, the release current when this was written), the project name: and the path are ours.
sudo sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
sudo ufw allow OpenSSH
sudo ufw --force enable
sudo mkdir -p /opt/changedetection
sudo tee /opt/changedetection/compose.yaml > /dev/null <<'EOF'
name: changedetection
services:
changedetection:
image: dgtlmoon/changedetection.io:0.60.9
container_name: changedetection
hostname: changedetection
volumes:
- changedetection-data:/datastore
environment:
- PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
ports:
- 127.0.0.1:5000:5000
restart: unless-stopped
depends_on:
browser-sockpuppet-chrome:
condition: service_started
browser-sockpuppet-chrome:
hostname: browser-sockpuppet-chrome
image: dgtlmoon/sockpuppetbrowser:latest
cap_add:
- SYS_ADMIN
restart: unless-stopped
environment:
- SCREEN_WIDTH=1920
- SCREEN_HEIGHT=1024
- SCREEN_DEPTH=16
- MAX_CONCURRENT_CHROME_PROCESSES=10
volumes:
changedetection-data:
EOF
sudo docker compose -f /opt/changedetection/compose.yaml up -d
The official file notes that SYS_ADMIN might be more than the browser needs but can be required on some platforms. If every page you watch is static HTML, delete the browser service, the depends_on block and the PLAYWRIGHT_DRIVER_URL line.
Docker publishes a mapping that names no address on 0.0.0.0 and [::] alike. IPv4 packets for it bypass the ufw chains; IPv6 packets bypass them only where Docker handles IPv6 itself; elsewhere they reach docker-proxy on [::], which ufw does filter. The official file binds to 127.0.0.1 and so avoids both, and with Engine 28 or newer a loopback binding stays host-local (moby#45610). The browser service publishes no port at all.
Password first, over an SSH tunnel
A new install has no password. In the source, the default settings carry password: False (model/App.py), and the login check asks only once a password exists (auth_decorator.py). The wiki covers it in one line: enter a password on the Settings page. Do that before any proxy makes the UI public; the order is ours.
- From your workstation, with your own login:
ssh -N -L 5000:127.0.0.1:5000 you@server.example.com, and browse tohttp://localhost:5000. - Settings, General: enter a strong password and save. Log out and back in to confirm it is asked for.
- Look at the API tab. The default
api_access_token_enabled: True(model/App.py) makes every API call carry the key in anx-api-keyheader (api/auth.py), and this tab shows that key to anyone who can open the UI: one more reason the UI stays private until the password is set. - Lost password, per the wiki:
sudo docker exec changedetection touch /datastore/removepassword.lock, thensudo docker restart changedetectionremoves it.
Leave ALLOW_IANA_RESTRICTED_ADDRESSES unset. The Compose file says it is off by default, which keeps watches from fetching private, local-only and other restricted address ranges.
HTTPS: DNS, then the certificate, then the Base URL
Do it in this order. Create an A record (and AAAA, if the server has IPv6) for a name such as watch.example.com and wait until dig +short returns the server's address. Then open 80 and 443 and start Caddy with the wiki's subdomain example. The certificate step is ours, not the wiki's: Caddy itself, not changedetection.io, requests one for the name when it loads the site block, which is why the record must resolve first. Caddy runs on the host as an ordinary process, so without the ufw rule it is blocked on IPv4 and IPv6 alike. We wrote 127.0.0.1:5000 where the wiki has localhost:5000, to match the loopback binding.
dig +short watch.example.com
sudo ufw allow 80,443/tcp
sudo apt install -y caddy
sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
watch.example.com {
reverse_proxy 127.0.0.1:5000
}
EOF
sudo systemctl reload caddy
Once https://watch.example.com loads with a valid certificate, change the config. Put - USE_X_SETTINGS=1 and - BASE_URL=https://watch.example.com into the compose file's environment: list and run sudo docker compose -f /opt/changedetection/compose.yaml up -d. The wiki says USE_X_SETTINGS makes the app respect forwarded headers such as X-Forwarded-Proto. BASE_URL goes into notifications and RSS links only; it does not fix the app's own links, which is the proxy's job. A Base URL typed into Settings overrides the environment value (store/__init__.py). Prefer a GUI proxy? See our Nginx Proxy Manager build.
Notifications and proxies
Alerts go through the Apprise library: each watch's edit tab takes one or more notification URLs such as discord://webhook_id/webhook_token or mailto://user:pass@example.com?to=receivingAddress@example.com, and the README says titles and bodies accept Jinja2 templates. The wiki adds the traps: Discord fails silently past 2,000 characters, an empty body breaks many targets, and a JSON POST should use post:// or posts://, not json://. For a push channel you also run yourself, see ntfy self hosted.
- One proxy for everything: environment variables
HTTP_PROXYandHTTPS_PROXY, withNO_PROXYas the exclude list. The wiki notes they apply to notifications too. - A choice per watch: a
proxies.jsonin the datastore, each entry a label and a URL;reuse_time_minimumsets the seconds between calls through that proxy. Its URLs hold credentials, so create the file private before writing to it:sudo install -m 600 /dev/null /opt/changedetection/proxies.json, edit it withsudo nano /opt/changedetection/proxies.json, thensudo docker compose -f /opt/changedetection/compose.yaml cp /opt/changedetection/proxies.json changedetection:/datastore/proxies.jsonand restart the container. - With Chrome, the wiki's table lists
playwright_proxy_server. The proxy tab in Settings limits authenticated SOCKS5 to the plain fetcher.
Backups, a live restore, and updates
Settings, Backups builds a zip in the background and offers it for download. According to the source (blueprint/backups), that zip holds changedetection.json, every watch and group folder with its history, and two URL lists. It leaves out secret.txt, proxies.json and plugin settings files, and the in-app restore imports watches and groups only. Keep the zip as a portable export; the complete backup is the whole volume, archived below with the app briefly stopped. Fourteen days of retention is an example. Ship the archives off the box yourself, with rsync or scp to a second host.
sudo mkdir -m 700 -p /srv/cd-backup /var/log/cd-backup
sudo tee /opt/changedetection/backup.sh > /dev/null <<'EOF'
set -eu
umask 077
f=/opt/changedetection/compose.yaml
out=/srv/cd-backup/datastore-$(date +%F).tgz
docker compose -f "$f" stop changedetection
ok=0
docker compose -f "$f" run --rm --no-deps -T --entrypoint tar changedetection -czf - -C /datastore . > "$out.part" && ok=1
docker compose -f "$f" start changedetection
if [ "$ok" = 1 ]; then mv "$out.part" "$out"; else rm -f "$out.part"; exit 1; fi
find /srv/cd-backup -name 'datastore-*.tgz' -mtime +14 -delete
EOF
printf '%s\n' 'SHELL=/bin/sh' '20 3 * * * root umask 077; bash /opt/changedetection/backup.sh > /var/log/cd-backup/run-$(date +\%F).log 2>&1' | sudo tee /etc/cron.d/changedetection-backup > /dev/null
To restore over a live install, set F=/opt/changedetection/compose.yaml and A= the archive you want, then:
sudo test -s "$A" && sudo tar -tzf "$A" > /dev/null && echo archive OK. Go on only if archive OK appears.sudo docker compose -f "$F" stop changedetection.- Set the current data aside:
sudo bash -c "set -o pipefail; docker compose -f $F run --rm --no-deps -T --entrypoint tar changedetection -czf - -C /datastore . > /srv/cd-backup/pre-restore.tgz". - Check the safety copy:
sudo tar -tzf /srv/cd-backup/pre-restore.tgz > /dev/null && echo pre-restore ok. Stop here unless pre-restore ok appears. - Only then empty the volume:
sudo docker compose -f "$F" run --rm --no-deps --entrypoint find changedetection /datastore -mindepth 1 -delete. sudo cat "$A" | sudo docker compose -f "$F" run --rm --no-deps -T --entrypoint tar changedetection -xzf - -C /datastore. The datastore is plain JSON files and folders with no database, so there are no -wal or -shm files to clear, and the Dockerfile sets noUSER: the container runs as root, so tar restores ownership exactly as archived.sudo docker compose -f "$F" up -d. Log in with the password that was current when the archive was made, and check the watch list and one history page.- When all is well,
sudo rm /srv/cd-backup/pre-restore.tgz.
Updating, per the README, is docker compose pull then docker compose up -d. With a pinned tag: take a backup with sudo bash /opt/changedetection/backup.sh, edit the tag, then pull and up with -f as above. Before a schema change the source writes a before-update- tarball of settings and watch JSON into the datastore (store/updates.py), without the history, so your own archive is the rollback: put the previous tag back, then restore the archive with the steps above.
Licence, responsibilities and limits
LICENSE: Apache License 2.0; copyright holders Leigh Morresi and the changedetection.io contributors. The README frames commercial use as a request, not a condition: those who earn money with the software are asked to consider the paid hosted subscription, used or not, and the author offers commercial support. Our general reading, not legal advice: running it, commercially included, owes nothing under the licence. Anyone passing copies on, such as a modified image, must include the licence, retain copyright and attribution notices and mark changed files; Apache-2.0 does not oblige them to publish source.
- The README's disclaimer makes you responsible for complying with the terms,
robots.txtand access policies of every site you monitor. - AI features send page diffs to the provider you configure;
LLM_FEATURES_DISABLED=trueturns them off, andDISABLE_VERSION_CHECK=truestops the version check. - It watches content, not availability. For up and down checks run Uptime Kuma self hosted beside it.
- You run the stack: the OS, Docker, image updates and restore tests stay with you. Basic management, included on the dedicated range, means a 12-hour response and 24-hour hardware replacement.
Questions
Which port does changedetection.io listen on?
Port 5000; the Dockerfile exposes it. The PORT variable changes the listening port inside the container. In the official Compose file the mapping is 127.0.0.1:5000:5000: use an SSH tunnel to get to it, or a reverse proxy on the same host.
Is the Chrome container required?
No. Without it the app uses its plain HTTP fetcher, which is enough for static pages. Pages assembled by JavaScript, the Visual Selector and Browser Steps need the Playwright fetcher, which the Compose file provides through sockpuppetbrowser.
Does a fresh install ask for a login?
No. The default settings have no password and the UI is open to anyone who can reach it. Set one under Settings, General, over an SSH tunnel, before exposing the port.
Docker Hub or GHCR: which image is official?
Both. The README runs dgtlmoon/changedetection.io from Docker Hub and names ghcr.io/dgtlmoon/changedetection.io as the alternative repository; the Compose file in the repo uses the GHCR name.
Can I run it without Docker?
Yes. The README installs it with pip3 install changedetection.io and starts it with changedetection.io -d /path/to/empty/data/dir -p 5000. You then manage the Python environment and a service unit yourself, and Chrome fetching needs a separate browser service.
Will the Backups zip restore everything?
No. In the source, the in-app restore imports watches and groups from the zip. Global settings such as the password, notification defaults and the proxy list come back only from a full copy of the datastore volume.