What you are setting up
changedetection.io checks the pages you give it on a schedule and tells you when something on them changes: a price, a stock status, a careers page, a policy document. The project ships it as a Docker image (ghcr.io/dgtlmoon/changedetection.io, also on Docker Hub as dgtlmoon/changedetection.io) and a ready Compose file. Its Dockerfile opens port 5000 and keeps all data in /datastore.
For pages that only fill in with JavaScript, the Compose file carries a commented-out browser service, dgtlmoon/sockpuppetbrowser, which the README recommends for Chrome-based fetching. It also powers the Visual Selector, where you click the part of a page you care about, and Browser Steps, which log in or click through a page before checking it.
Will a small server do?
That depends mostly on Chrome. The project publishes no minimum CPU or memory figures, so here is what makes it work harder, all from comments in the official Compose file: Chrome rendering (the example sets 10 browser processes at once), the number of parallel fetchers (10 by default) and taller screenshots, which it warns use more memory. Without Chrome, the README's fast non-JavaScript fetcher reads each page with a plain request and no browser.
| Your monitor | Server family on our range | Why |
|---|---|---|
| Plain pages, no Chrome | Small and cheap cartridges | The range describes them as ideal when you have one job to run |
| Plenty of JavaScript pages through Chrome | Everyday servers (one processor) | Headroom for the browser beside the app |
| The monitor plus several other services | Serious workloads (two processors) | Room for many jobs without them fighting |
That matching is our suggestion, not the project's. Whichever you pick, it is a whole physical machine with root access and an unmetered port, on the dedicated server range. Ready servers reach you within 30 minutes of approval; builds to your own specification take longer. Rack servers and blades have a console screen and power button you can open yourself, and so do two cartridge models, the m510 and m710x; every other cartridge gives you power on and off and nothing more. Leave the ARM cartridge out of this job: its only systems are Ubuntu 16.04 and 18.04, and these steps assume a current Ubuntu.
Up and running, step by step
Your server needs a current Ubuntu release, Docker Engine 28 or newer, and the Compose v2 plugin. Type sudo -v before pasting, so a password prompt cannot eat one of the lines. The block below turns on the firewall with only SSH allowed, IPv6 included, before anything starts. What goes into the Compose file comes straight from the project's own, with the browser service switched on; the version pin (0.60.9, current when we wrote this), the project name: and the folder are our choices.
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: ghcr.io/dgtlmoon/changedetection.io:0.60.9
container_name: changedetection
hostname: changedetection
restart: unless-stopped
ports:
- 127.0.0.1:5000:5000
volumes:
- changedetection-data:/datastore
environment:
- PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
depends_on:
browser-sockpuppet-chrome:
condition: service_started
browser-sockpuppet-chrome:
image: dgtlmoon/sockpuppetbrowser:latest
hostname: browser-sockpuppet-chrome
restart: unless-stopped
cap_add:
- SYS_ADMIN
environment:
- SCREEN_WIDTH=1920
- SCREEN_HEIGHT=1024
- SCREEN_DEPTH=16
- MAX_CONCURRENT_CHROME_PROCESSES=10
volumes:
changedetection-data:
EOF
cd /opt/changedetection && sudo docker compose up -d
Only watching simple pages? Remove the browser service, its depends_on entry and the line with PLAYWRIGHT_DRIVER_URL, and you run a single container. The official file comments that SYS_ADMIN may be more than needed but is required on some platforms. Every docker compose command from here on assumes you are in /opt/changedetection, so start each session with cd /opt/changedetection.
Why 127.0.0.1:5000:5000 and not 5000:5000: leave the address out and Docker listens on every address the host has, IPv4 0.0.0.0 and IPv6 [::]. Its IPv4 forwarding gets around ufw entirely. IPv6 traffic does so too when Docker is in charge of IPv6; if it is not, the [::] side is served by docker-proxy, which ufw does filter. The loopback address sidesteps all of that, and Docker Engine 28 onwards keeps such a port reachable only from the server itself (moby#45610).
Lock it with a password before anyone else can see it
Out of the box there is no password, and the interface is open to whoever can load it. You can see this in the code: the default settings store password: False, and the login check only kicks in once one is set (model/App.py, auth_decorator.py). The wiki's instruction is just to type one into the password box under Settings. Doing that before the site goes public is our advice.
- On your own computer, open a tunnel with your usual login:
ssh -L 8085:127.0.0.1:5000 you@your-server-ip. - Browse to
http://localhost:8085, open Settings, stay on the General tab, type a strong password and save. - Forgot it later? The wiki's reset is a marker file in the data folder, followed by a restart; from
/opt/changedetection:sudo docker compose exec changedetection touch /datastore/removepassword.lock, and after thatsudo docker compose restart changedetection.
While you are in Settings, note the API tab. Out of the box the API insists on a key, sent in the request header x-api-key (api_access_token_enabled in model/App.py, checked in api/auth.py), and that tab displays the key to whoever can open the interface. That is another reason it stays behind your tunnel until it has a password.
Putting it on the web with HTTPS
Go in this order: name, certificate, then app settings. Create a DNS record, say watch.example.com, holding the server's address, and check that dig +short watch.example.com returns it before you go on. Then the block below opens ports 80 and 443 and adds a server block adapted from the wiki's nginx example. That example stops at plain HTTP, so certbot, the certificate and this order (certificate before Base URL) are our additions. So are these changes: a subdomain at / rather than the wiki's /app/ subfolder, hence no X-Forwarded-Prefix; the real Host instead of "localhost"; 127.0.0.1:5000 instead of the container alias, because nginx runs on the host; an IPv6 listen; and X-Forwarded-For. nginx is an ordinary program on the server, so until ufw allows those ports, visitors over IPv4 and IPv6 alike get nowhere. The three Upgrade-related lines are our addition: the source runs a Socket.IO server for live updates in the list (realtime/), and those lines let its websocket through.
sudo ufw allow 80,443/tcp
sudo apt install -y nginx certbot python3-certbot-nginx
sudo tee /etc/nginx/sites-available/changedetection > /dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name watch.example.com;
location / {
proxy_pass http://127.0.0.1:5000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
sudo ln -s /etc/nginx/sites-available/changedetection /etc/nginx/sites-enabled/changedetection
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d watch.example.com
With the padlock showing, tell the app about its new home by adding two lines to the environment: list in the compose file: - USE_X_SETTINGS=1, which the wiki says makes it respect the headers nginx sends, and - BASE_URL=https://watch.example.com. Apply them with cd /opt/changedetection && sudo docker compose up -d. The Base URL is what your notifications and RSS links point back to; the wiki stresses that the app's own links are a matter for the proxy. A value typed into Settings wins over the environment (store/__init__.py). Rather click than edit files? Nginx Proxy Manager does the same job through a web panel.
Getting alerts where you will see them
Notifications run on the Apprise library, so you describe each destination as a URL in the watch's edit tab, for example discord://webhook_id/webhook_token for a Discord channel or a mailto:// address for email. A few lessons from the wiki: Discord drops messages over 2,000 characters without saying so, an empty body confuses many services, and for a JSON POST to your own endpoint you want post:// or posts://. Want a push app you also host yourself? See ntfy self hosted.
When checks need to leave through another address, the wiki gives you two ways. For everything at once, set the environment variables HTTPS_PROXY and HTTP_PROXY, plus NO_PROXY for addresses to skip; the wiki notes these cover notifications too. To choose per watch, put a proxies.json file in the data folder listing each proxy with a label and URL, and optionally reuse_time_minimum to space out calls. That file holds proxy passwords, so make it private before typing any: cd /opt/changedetection && sudo install -m 0600 -T /dev/null proxies.json, then sudo nano proxies.json. Copy it in with sudo docker compose cp proxies.json changedetection:/datastore/ and restart the container. Chrome fetches use playwright_proxy_server instead, and the proxies tab in Settings warns that SOCKS5 proxies needing a username and password are limited to the plain fetcher.
Keeping your watches safe
Settings has a Backups tab that builds a downloadable zip. Its code (blueprint/backups) shows the zip carries your settings file, each watch and group with its history, and URL lists, but not secret.txt, proxies.json or plugin settings; uploading it back imports only watches and groups. Treat it as an export, and back up the whole data volume too. The script below stops the app for the few moments the copy takes, starts it again even if the copy fails, and keeps 14 days, a number you can change. Also move copies off this server regularly: an archive that only lives here is lost with it.
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
cd /opt/changedetection
out=/srv/cd-backup/datastore-$(date +%F).tgz
docker compose stop changedetection
ok=0
docker compose run -T --rm --no-deps --entrypoint tar changedetection -C /datastore -czf - . > "$out.part" && ok=1
docker compose 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' '40 2 * * * 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
Bringing a backup back onto the running install takes seven steps. Run cd /opt/changedetection and set B= to the path of the archive you picked.
- Make sure the archive is there and readable:
sudo test -s "$B" && sudo tar -tzf "$B" > /dev/null && echo "backup readable". If you do not see backup readable, stop here. - Stop the app:
sudo docker compose stop changedetection. - Keep what is there now:
sudo bash -o pipefail -c "cd /opt/changedetection && docker compose run -T --rm --no-deps --entrypoint tar changedetection -C /datastore -czf - . > /srv/cd-backup/before-restore.tgz". - Test that copy:
sudo tar -t -z -f /srv/cd-backup/before-restore.tgz > /dev/null && echo "safety copy ok". No safety copy ok, no next step. - Now, and only now, clear the volume:
sudo docker compose run --no-deps --rm --entrypoint sh changedetection -c 'find /datastore -mindepth 1 -delete'. - Unpack the backup into it:
sudo cat "$B" | sudo docker compose run -T --rm --no-deps --entrypoint tar changedetection -C /datastore -xzf -. The data folder holds JSON files and per-watch folders, not a database, so nothing like -wal or -shm leftovers exists. There is noUSERline in the Dockerfile; the container's processes run as root, which lets tar put the original file owners back. - Start everything again:
sudo docker compose up -d. Log in using the password that was valid when the backup was taken, and open a watch's history to check it. - Happy with it? Remove the safety copy:
sudo rm /srv/cd-backup/before-restore.tgz.
For new versions, the README's update is a pull followed by up -d. Because this file pins a version, first run the backup script by hand (sudo bash /opt/changedetection/backup.sh), then edit the version tag, and run sudo docker compose pull and sudo docker compose up -d from /opt/changedetection. Before reshaping its data for a new version, the app saves its own tarball, named before-update- plus the update number and a timestamp, of settings and watch files into the data folder (store/updates.py), without the history. If the new version disappoints you, switch the tag back to the previous version and restore your own archive as above.
The licence, your part, and when to choose differently
The code is licensed Apache-2.0, and the LICENSE file names Leigh Morresi together with the project's contributors as copyright holders. How the repo talks about commercial use: the README invites anyone making money from it to take out the paid, hosted plan as a way to support the project, whether or not they use it, and its author offers commercial support. Those are requests, not licence terms. Read this as our general understanding, not legal advice: you can run it for your business without owing anything. If you hand copies to others, for example a modified image, you pass on the licence text, keep the existing copyright and attribution notices intact and flag the files you changed; Apache-2.0 does not make you publish your source.
- The README puts compliance with each monitored site's terms,
robots.txtand access rules on you, not on the authors. - It tells you a page changed, not that a site is down. For uptime alerts, pair it with Uptime Kuma self hosted.
- You manage the server; we keep the hardware and the network running. Updates to the system, Docker and the app are yours.
Questions people ask
- What port should I open for changedetection.io?
- None directly. The app listens on 5000 inside its container, and the project's Compose file maps it to 127.0.0.1 only. Open 80 and 443 for the reverse proxy, and reach port 5000 yourself through an SSH tunnel.
- Do I have to run Chrome next to it?
- Only for pages whose content appears through JavaScript, or if you want the Visual Selector or Browser Steps. Plain pages are fetched without a browser. The sockpuppetbrowser service in the Compose file is the option the README recommends.
- How do I move my monitor to a new server?
- Copy your latest full archive across, write the same compose file, start it once so the volume exists, then follow the restore steps. Point your DNS record at the new address and get the certificate before switching the Base URL.