All posts

Changedetection on a VPS

Changedetection 0.60.10: 5000 only on 127.0.0.1, SALTED_PASS login required, one watch fetched, Caddy reverse proxy. Idle 60.0 MiB cgroup.

Written by NodeRings Inc.

NodeRings Inc. writes these notes. The company runs the marketplace and the agent that stays on the provider’s side of the hypervisor.

Changedetection 0.60.10 held 60.0 MiB idle on this lab host after a password login and one completed check of https://example.com/. That is cgroup memory.current, the median of twenty samples taken 30 seconds apart with a quiet UI. docker stats printed 60.0 MiB. Process PSS from /proc/…/smaps was 74.9 MiB, and CPU sat at 0.21 percent. Port 5000 listened on 127.0.0.1 only. A curl to the guest's LAN address on 5000 was refused. An anonymous GET of / returned HTTP 302 to /login.

changedetection.io watches web pages and notifies you when the text (or a CSS/JSON selector) changes. Use it for price drops, status pages, procurement notices, or docs that never send RSS. The core fetchers cover simple HTTP; Playwright/sockpuppet browsers handle heavier JS sites at a higher RAM cost.

The upstream compose already shows 127.0.0.1:5000:5000, followed by a long comment block. Competing guides still publish 5000:5000 and leave the UI open. Pin 0.60.10 by digest. Set SALTED_PASS before the hostname is public, create one watch, and keep Caddy as the only public path. Playwright and the sockpuppet browser stayed out of this idle run.

ReadingValue
cgroup memory.current60.0 MiB, median of 20
docker stats60.0 MiB
process PSS (smaps)74.9 MiB
CPU percent0.21, median
Host listener127.0.0.1:5000 → container :5000
Anonymous /HTTP 302 → /login
One watchexample.com lab, first check stored
Skip thisWhat this run did
5000:5000 on every interfacePublished 127.0.0.1:5000:5000
No password until "later"SALTED_PASS from day one
Floating latest without a digestPinned 0.60.10 by digest
Opening 5000 on the panel firewallLeft 5000 closed; Caddy on 80/443
Counting a Chrome sidecar in idleLeft Playwright out of this stack
Bar chart. Idle memory.current 60.0 MiB, docker stats 60.0 MiB, process PSS 74.9 MiB.
Idle after password login and one watch check. More watches and a browser fetcher sit above this.

How do you install Changedetection on Docker?

The image is dgtlmoon/changedetection.io:0.60.10, digest sha256:72f97681f390e4d6ae3e6585e1e0036449910f347144b766fc23910458566a1a. On this pull, latest pointed at the same digest. Pin 0.60.10 in Compose. ghcr.io/dgtlmoon/changedetection.io is the same project if you prefer GHCR.

Generate the password hash the app expects. SALTED_PASS is the salt plus the pbkdf2 key, encoded in base64.

python3 - <<'PY'
import base64, hashlib, os
password = "YOUR_LONG_PASSWORD"
salt = os.urandom(32)
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
print(base64.b64encode(salt + key).decode())
PY
mkdir -p ~/changedetection/datastore && cd ~/changedetection
services:
  changedetection:
    image: dgtlmoon/changedetection.io:0.60.10@sha256:72f97681f390e4d6ae3e6585e1e0036449910f347144b766fc23910458566a1a
    container_name: changedetection
    hostname: changedetection
    restart: unless-stopped
    ports:
      - "127.0.0.1:5000:5000"
    environment:
      SALTED_PASS: "PASTE_THE_BASE64_OUTPUT_HERE"
      BASE_URL: https://changes.example.com
      USE_X_SETTINGS: "1"
      DISABLE_VERSION_CHECK: "true"
      HIDE_REFERER: "true"
      TZ: UTC
    volumes:
      - ./datastore:/datastore
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Put the base64 hash in a mode 600 .env if you prefer Compose variable substitution. For a first bring-up on loopback only, set BASE_URL=http://127.0.0.1:5000 until Caddy and DNS are ready. Log rotation matches the Compose checklist.

docker compose up -d
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:5000/

You want 302 and a redirect to /login.

Compose for Changedetection 0.60.10 with 127.0.0.1:5000:5000 and a salted password.
Loopback publish, salted password, datastore on one volume.

How do you tune watches and notifications?

Set SALTED_PASS before the UI is public (PBKDF2 salt+hash as in changedetection#1531). Start with one watch, a sensible interval, and a notification you already receive. Prefer CSS selectors over whole-page diffs. Set BASE_URL to the public HTTPS origin so notification links resolve, and HIDE_REFERER=true if you do not want monitored sites to see your hostname (compose comments). Add Playwright/sockpuppetbrowser only when the plain fetcher returns empty; it needs SYS_ADMIN and real RAM (Playwright docs). Cap growth with PAGE_WATCH_LIMIT if needed. DISABLE_VERSION_CHECK=true turns off the phone-home version probe.

Back up /datastore regularly. Keep the app on loopback and Caddy on 443.

Why must 5000 stay on loopback?

Changedetection holds every URL you watch, plus diffs and an API token. Anyone who can reach an open UI can read that list.

ss -H -lntup | grep 5000

On this host that printed 127.0.0.1:5000. Curl to 192.168.1.150:5000 failed with connection refused. Leave 5000 closed in the panel firewall. Open 80 and 443 for Caddy. Docker published ports often bypass UFW inside the guest, so the loopback bind is what kept the LAN address quiet. The public-address case is in provider-side firewall.

On a Mac guest the upstream compose notes port 5050 because AirPlay grabs 5000. On a Linux VPS, 5000 on loopback is fine.

ss showing LISTEN on 127.0.0.1:5000 only, and curl to the LAN address refused.
Host view of the publish. The process inside still binds container port 5000.

How do you prove the password gate?

With SALTED_PASS set, Changedetection redirects an anonymous request to /login.

curl -sS -D- -o /dev/null http://127.0.0.1:5000/

This lab returned HTTP 302 with Location: /login?redirect=/. /login itself returned 200 and a password form. Crop the password from any screenshot.

Open a tunnel if you are not on the guest.

ssh -N -L 15000:127.0.0.1:5000 user@YOUR_VPS_IP

Then sign in at http://127.0.0.1:15000/login with the plaintext password you hashed.

Anonymous curl to / returning 302 Location /login, and /login returning 200.
Anonymous `/` redirects to login. The salt does not leave the environment or the datastore.

How do you add one watch?

A fresh install ships with a couple of example watches. Add your own in the UI or through the API. The API access token lives under Settings and in datastore/changedetection.json as api_access_token.

curl -sS -X POST http://127.0.0.1:5000/api/v1/watch \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/","title":"example.com lab"}'

This lab returned {"uuid":"c176374a-484e-44a4-af32-caf701b804ef"}. A few seconds later the same UUID showed a non-zero last_checked and a history file under the datastore. Crop the API key.

curl -sS -H "x-api-key: YOUR_API_KEY" \
  http://127.0.0.1:5000/api/v1/watch/c176374a-484e-44a4-af32-caf701b804ef

For phone alerts, an Apprise notification URL can point at your ntfy topic. You can skip that for the loopback proof.

API create of example.com lab watch returning a uuid, then history after first check.
Create returns a uuid. History appears after the first successful check.
Changedetection watch list after login showing example.com lab with a completed check.
UI on loopback after login. example.com lab checked; status Not yet until the page changes.

Where does Caddy go?

changes.example.com {
	reverse_proxy 127.0.0.1:5000
}

Set BASE_URL to that same https://changes.example.com (no trailing slash). USE_X_SETTINGS=1 makes Changedetection respect proxy Host and prefix headers. TLS setup is in the Caddy HTTPS post.

Caddyfile reverse proxy to 127.0.0.1:5000 for changes.example.com.
Public HTTPS on 443. Changedetection stays on loopback HTTP.

How do you back up the datastore?

Watches, diffs, the secret key, and settings live under ./datastore. Stop the container, archive the directory, start again:

docker compose stop changedetection
tar -czf changedetection-datastore.tgz datastore
docker compose start changedetection

On this lab the archive was about 127 KB after the stock examples plus one example.com watch. Point Restic at that tarball.

Stop, tar datastore into changedetection-datastore.tgz at 127K, start again.
Stopped copy of watches, diffs, and secret.txt.

How do you keep watches useful?

Retire watches that only change boilerplate. Use screenshots/visual modes sparingly; they cost more CPU. Group watches by vendor. When a site blocks datacenter IPs, you may need a residential proxy or to accept defeat. Notifications should include the diff URL so you can verify before acting on a price.

FAQ

Why set SALTED_PASS before the first request? If 5000 is reachable before you click through Settings, the UI is open. The env var is set before the first request.

Playwright? JS-heavy sites need a browser sidecar (PLAYWRIGHT_DRIVER_URL). This idle number is the core app only, so budget more RAM when you add Chrome.

Does UFW block a published 5000? Often not inside the guest. Bind 127.0.0.1:5000:5000 and leave 5000 closed on the panel firewall.

Mac AirPlay on 5000? Upstream suggests 127.0.0.1:5050:5000. On a Linux VPS, keep 5000 on loopback.

ALLOW_FILE_URI? Leave it off unless you deliberately watch file:// paths and understand the risk.

Version check? This lab set DISABLE_VERSION_CHECK=true so the container does not phone home for updates. Pin digests yourself.