Skip to content
Get Started

How to Self-Host changedetection.io with Docker

changedetection.io monitors web pages and records what changed between fetches: price drops, restocks, quiet edits to a page you care about. The safe way to run it on a public domain is to start with the plain HTTP fetcher, keep port 5000 on loopback, and set the application password before the domain goes live. A browser fetcher can come later if a specific site needs one; adding it on day one only hands untrusted JavaScript a larger container and network surface without helping ordinary HTML pages.

The deployment below runs changedetection.io 0.55.8 on a Debian or Ubuntu VPS. Caddy terminates HTTPS at change.example.com; the application itself listens only on 127.0.0.1:5000.

The install itself is only a few files. The ongoing work, and where this guide spends most of its time, is deciding what the service may reach and making sure its data can actually be restored. A good rule of thumb: use the smallest fetcher that can monitor the target, and remember that every watch URL is a network request your server makes on someone’s behalf.

Prepare the VPS, DNS, and firewall

You need a VPS with root or sudo access, an A record for change.example.com, an AAAA record only when IPv6 works end to end, and encrypted backup storage outside the VPS. TCP 80 and 443 must reach Caddy. Keep your administrative SSH port open, and leave TCP 5000 closed in UFW and the provider firewall; Caddy reaches the application over loopback.

Install Docker Engine and the Compose plugin with Docker’s convenience script (inspect it first if that worries you; on a tightly managed host, use your distribution’s packages instead):

curl -fsSL https://get.docker.com -o get-docker.sh
sudo bash get-docker.sh
sudo docker compose version
sudo apt update
sudo apt install -y caddy jq ufw

That version check aside, the rest of this guide runs docker without sudo, so either add your user to the docker group (sudo usermod -aG docker $USER, then log out and back in) or prefix the docker commands with sudo.

This firewall example assumes SSH listens on port 22. Change that rule before enabling UFW if your server uses another port.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

Apply the same inbound policy at the hosting-provider firewall, and confirm the domain resolves to this server with getent ahosts change.example.com.

Create the Docker Compose project

The official 0.55.8 Compose file uses one application container, a named volume at /datastore, and the correct loopback port binding. Keep that architecture for the baseline deployment.

sudo install -d -m 750 -o "$USER" -g "$USER" /opt/changedetection
cd /opt/changedetection
umask 077

Create /opt/changedetection/compose.yml:

services:
  changedetection:
    image: ghcr.io/dgtlmoon/changedetection.io:0.55.8
    container_name: changedetection
    hostname: changedetection
    environment:
      BASE_URL: "https://change.example.com"
      USE_X_SETTINGS: "1"
      LLM_FEATURES_DISABLED: "true"
      HIDE_REFERER: "true"
      TZ: "UTC"
    volumes:
      - changedetection-data:/datastore
    ports:
      - "127.0.0.1:5000:5000"
    logging:
      options:
        max-size: "10m"
        max-file: "3"
    restart: unless-stopped

volumes:
  changedetection-data:

BASE_URL gives notifications the public HTTPS origin. USE_X_SETTINGS=1 lets the application honor proxy host and scheme headers. That setting is safe here because Docker accepts port 5000 only from the VPS loopback path and Caddy controls the forwarded headers. Do not combine it with a public 0.0.0.0:5000 binding.

LLM_FEATURES_DISABLED=true prevents watched content and diffs from being sent to an LLM provider through the application’s AI features. HIDE_REFERER=true applies a referrer policy so monitored sites do not learn the changedetection.io hostname when an operator follows a link. Neither setting makes stored watch data public-safe.

Validate and start the service:

cd /opt/changedetection
docker compose config --quiet
docker compose up -d
docker compose logs --tail=50 changedetection
sudo ss -lntp | grep ':5000'

The listener must show 127.0.0.1:5000, never 0.0.0.0:5000 or [::]:5000.

Set the password before public exposure

changedetection.io is not a multi-user application with a first-account wizard. A default installation has no password. The official password documentation instructs operators to set one in Settings.

Leave Caddy unconfigured. From your workstation, open an SSH tunnel and keep it running:

ssh -N -L 5000:127.0.0.1:5000 YOUR_USER@SERVER_IP

Browse to http://127.0.0.1:5000, open Settings, set a strong unique password, and save. Sign out, reload the page, and confirm the password is required and works. This order prevents an Internet visitor from choosing or bypassing the initial protection.

If the password is ever lost, follow the upstream reset procedure but use this deployment’s real container name, changedetection. Reset access only from an authenticated administrative session, then set a new password immediately.

Add Caddy only after the password works

Create a tiny test resource on the same HTTPS origin. It gives the later watch test a URL you control rather than making a third-party site part of service health.

sudo install -d -m 750 -o root -g caddy /srv/changedetection-test
printf '%s\n' 'changedetection test version 1' | \
  sudo tee /srv/changedetection-test/probe.txt > /dev/null
sudo chown root:caddy /srv/changedetection-test/probe.txt
sudo chmod 640 /srv/changedetection-test/probe.txt

Keep the site in an imported snippet so it does not replace any existing Caddy hosts:

sudo install -d -m 755 /etc/caddy/sites-enabled
sudo tee /etc/caddy/sites-enabled/changedetection.caddy > /dev/null <<'CADDY'
change.example.com {
    handle /changedetection-probe.txt {
        root * /srv/changedetection-test
        rewrite * /probe.txt
        file_server
    }

    handle {
        reverse_proxy 127.0.0.1:5000
    }
}
CADDY

if ! sudo grep -Eq \
  '^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*' \
  /etc/caddy/Caddyfile; then
  printf '\nimport /etc/caddy/sites-enabled/*\n' | \
    sudo tee -a /etc/caddy/Caddyfile > /dev/null
fi

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -fsS https://change.example.com/changedetection-probe.txt

Caddy’s automatic HTTPS obtains and renews the certificate when DNS and ports 80/443 are correct. The public root should now show the password-protected application.

Verify system information and a real change

Find the API key under Settings > API. The official API authenticates requests with the x-api-key header. Read the key without adding it to shell history:

read -rsp 'changedetection.io API key: ' CD_API_KEY
printf '\n'
export CD_API_KEY
export CD_URL='https://change.example.com'

curl -fsS -H "x-api-key: $CD_API_KEY" \
  "$CD_URL/api/v1/systeminfo" | jq

Confirm that version is 0.55.8 and review queue_size, overdue_watches, uptime, and watch_count.

Create a watch using the default html_requests fetcher, trigger it, and wait for the first snapshot:

probe="$CD_URL/changedetection-probe.txt"
watch_uuid=$(curl -fsS -X POST "$CD_URL/api/v1/watch" \
  -H "x-api-key: $CD_API_KEY" \
  -H 'Content-Type: application/json' \
  --data "$(jq -nc --arg url "$probe" \
    '{url:$url,title:"Controlled Compose test",fetch_backend:"html_requests"}')" \
  | jq -er '.uuid')

curl -fsS -H "x-api-key: $CD_API_KEY" \
  "$CD_URL/api/v1/watch/$watch_uuid?recheck=true"

for attempt in $(seq 1 24); do
  watch_json=$(curl -fsS -H "x-api-key: $CD_API_KEY" \
    "$CD_URL/api/v1/watch/$watch_uuid")
  if [ "$(jq -r '.last_checked // 0' <<<"$watch_json")" -gt 0 ]; then
    jq '{last_checked,last_error,history_n,fetch_backend}' <<<"$watch_json"
    break
  fi
  sleep 5
done

Change the controlled file, trigger another check, and wait until the API reports at least two snapshots:

printf '%s\n' 'changedetection test version 2' | \
  sudo tee /srv/changedetection-test/probe.txt > /dev/null
sudo chown root:caddy /srv/changedetection-test/probe.txt
sudo chmod 640 /srv/changedetection-test/probe.txt

curl -fsS -H "x-api-key: $CD_API_KEY" \
  "$CD_URL/api/v1/watch/$watch_uuid?recheck=true"

for attempt in $(seq 1 24); do
  history_count=$(curl -fsS -H "x-api-key: $CD_API_KEY" \
    "$CD_URL/api/v1/watch/$watch_uuid/history" | jq 'length')
  [ "$history_count" -ge 2 ] && break
  sleep 5
done

curl -fsS -H "x-api-key: $CD_API_KEY" \
  "$CD_URL/api/v1/watch/$watch_uuid/difference/previous/latest?format=text"
curl -fsS -X DELETE -H "x-api-key: $CD_API_KEY" \
  "$CD_URL/api/v1/watch/$watch_uuid" > /dev/null
unset CD_API_KEY

The diff must show the change from version 1 to version 2. One pass through this test exercises DNS, TLS, authentication, queueing, outbound HTTP, snapshot persistence, and diff generation.

Back up the stopped datastore

/datastore holds watches, history, snapshots, headers, notification URLs, browser steps, and application secrets. An archive of it may contain credentials or tokens, so give it mode 600, encrypt it, and copy it off the VPS.

Stop the container first so nothing writes mid-archive, then tar the volume from a helper container:

cd /opt/changedetection
umask 077
install -d -m 700 backups
datastore_volume=$(docker inspect -f \
  '{{range .Mounts}}{{if eq .Destination "/datastore"}}{{.Name}}{{end}}{{end}}' \
  changedetection)
archive="backups/changedetection-$(date +%F-%H%M%S).tar.gz"

docker compose stop changedetection
docker run --rm \
  -v "$datastore_volume:/datastore:ro" \
  -v "$PWD/backups:/backup" \
  alpine:3.22 \
  tar -C /datastore -czf "/backup/$(basename "$archive")" .
docker compose start changedetection

sudo chown "$USER":"$USER" "$archive"
chmod 600 "$archive"
tar -tzf "$archive" > /dev/null
sha256sum "$archive" > "$archive.sha256"

Keep compose.yml, the Caddy configuration, and the checksum alongside the archive in encrypted backup storage.

Rehearse an isolated same-version restore

Restore into a throwaway environment rather than over production. The drill below builds a throwaway volume, network, and container with a timestamped name and starts the same 0.55.8 image that produced the backup. Keep the commands and the later cleanup in one shell:

cd /opt/changedetection
BACKUP=$(ls -1 backups/changedetection-*.tar.gz | sort | tail -n 1)
sha256sum -c "$BACKUP.sha256"

restore_id="changedetection-restore-$(date +%Y%m%d%H%M%S)"
docker volume create "$restore_id"
docker network create --internal "$restore_id"

docker run --rm \
  -v "$restore_id:/datastore" \
  -v "$PWD/backups:/backup:ro" \
  alpine:3.22 \
  tar -C /datastore -xzf "/backup/$(basename "$BACKUP")"

docker run -d --name "$restore_id" \
  --network "$restore_id" \
  -p 127.0.0.1:15000:5000 \
  -v "$restore_id:/datastore" \
  ghcr.io/dgtlmoon/changedetection.io:0.55.8
docker logs --tail=50 "$restore_id"

Tunnel port 15000, log in with the restored password, inspect a few watches and their histories, and run the API system-information check against the restored copy. The internal Docker network blocks scheduled fetches and notification callbacks from leaving the test environment, so fetch errors are expected. Do not connect optional browser automation to production credentials during a recovery drill. Remove the test resources when finished:

docker rm -f "$restore_id"
docker volume rm "$restore_id"
docker network rm "$restore_id"

Upgrade with a recovery point

Read the release notes, take a fresh cold backup, and rehearse its restore. Then bump the version tag in compose.yml and redeploy:

cd /opt/changedetection
docker compose config --quiet
docker compose pull changedetection
docker compose up -d
docker compose logs --tail=100 changedetection

Repeat /api/v1/systeminfo and the two-version watch test. Keep the previous image tag and archive until those checks pass. If an upgrade migrates stored data, rollback means restoring the old image and its pre-upgrade /datastore together. Changing the image tag back on its own may leave migrated data in an unsupported state.

Add a browser only for sites that require it

The baseline deliberately uses the pure HTTP html_requests fetcher. It consumes less memory, does not execute target JavaScript, and stays within changedetection.io’s request-fetching path. Dynamic pages may require the optional Playwright-compatible Sockpuppetbrowser service shown in the official Compose file.

Treat that as a separate change. The upstream example at the time of writing uses dgtlmoon/sockpuppetbrowser:latest and may add SYS_ADMIN; its own comment says that capability may be excessive. Pin a specific browser tag, test whether it runs without the capability, set CPU, memory, process, and screenshot limits, and publish neither its port 3000 nor a VNC port.

Place only changedetection.io and the browser on a dedicated Docker bridge. Private Docker networking prevents a public control port, but it does not restrict outbound destinations. Apply host or provider egress rules that deny cloud metadata, loopback, link-local, RFC1918, management, and storage networks while allowing the explicit container-to-container browser endpoint. Review those rules after every network change.

As of 0.55.8, changedetection.io blocks IANA-restricted addresses by default, rechecks DNS at fetch time, validates every redirect hop, and rejects file:// URLs, with defenses against DNS rebinding and URL-parser tricks; the ALLOW_IANA_RESTRICTED_ADDRESSES and ALLOW_FILE_URI switches both default to false. Resist the temptation to set ALLOW_IANA_RESTRICTED_ADDRESSES=true just to watch a NAS on your LAN. That global opt-in turns private addresses into valid targets for every permitted watch and makes stolen application access far more damaging. Browser traffic also needs network-layer controls because a full browser is not equivalent to the plain-request SSRF filter.

Those controls include fixes covered by the project’s SSRF security advisory. They reduce exposure in the supported configuration; they are not a reason to remove egress filtering or grant watch creation to untrusted users.

Choosing a Riven Cloud VPS for changedetection.io

For a personal watch list on the plain HTTP fetcher, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is a comfortable starting point. Every plan ships with a 1 Gbps port and full root access. These are unmanaged VPS, which means the stack is yours to run and yours to control. If you add browser workers, keep long snapshot histories, or watch many pages at short intervals, step up to Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB) for the extra memory and disk.

We offer Tokyo and Singapore. Pick the location that reaches both you and the sites you monitor well; a server close to your dashboard can still be far from a monitored origin. If you are on a mainland China network, the Tokyo Looking Glass and Singapore Looking Glass let you test real routes from your own connection before ordering. Current specs and prices are on the Riven Cloud VPS plans page.

Daily provider-side backups come with every plan and are a welcome safety net. Treat them as secondary, though: they can catch the application mid-write and they sit inside our recovery system, so the stopped /datastore archive, the encrypted off-server copy, and the restore drill above remain your primary recovery path.

Security limits and non-fit cases

A few habits keep the deployment healthy: keep the application password unique, protect API keys and notification tokens, review who can add watch URLs, and keep an eye on disk growth. Hiding the referrer does not make a fetch anonymous; the target still sees the VPS source IP, timing, request headers, and behavior, so be a polite client and respect site terms, robots policies where applicable, and sensible request rates.

Self-hosting is not always the right call, and that is fine. If nobody will own updates, outbound access policy, alert delivery, backups, and restore tests, the hosted changedetection.io service or another managed monitor will serve you better. A single VPS also cannot usefully monitor its own availability or network path, so give that job to an independent external monitor. And think twice when untrusted tenants can create arbitrary watches, when browser automation needs powerful access to internal systems, or when monitoring credentials call for formal secret management and audit controls.

Share