changedetection.io 0.60.10 on AWS User Guide
Overview
This guide covers launching and verifying changedetection.io 0.60.10 on Ubuntu 24.04 from cloudimg on Amazon EC2. It is one version of the cloudimg self-hosted personal apps listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.
changedetection.io is the open-source (Apache-2.0) website change monitor. Point it at any web page (a price, a stock level, terms and conditions, a job board, a release notes file, a government notice) and it re-checks the page on a schedule, keeps every snapshot, and shows you exactly what was added or removed. It can narrow a watch to a CSS, XPath or JSON region, trigger only on text you care about, and alert you through email, Slack, Discord, Telegram, Matrix, webhooks and the other channels of the Apprise library. Everything is manageable from the web UI or the REST API.
Version 0.60.10 includes the fix for GHSA-gwph-fp79-379w (high): the URL filter now also refuses the carrier-grade NAT range 100.64.0.0/10, which earlier releases let a watch fetch.
What is on the image:
- changedetection.io 0.60.10 from the official upstream container image
ghcr.io/dgtlmoon/changedetection.io:0.60.10, pinned by digestsha256:72f97681f390e4d6ae3e6585e1e0036449910f347144b766fc23910458566a1a - Docker CE (official Docker apt repository) running the container under the systemd unit
changedetection.service - A dedicated 20 GiB EBS data volume mounted at
/var/lib/changedetectionfor the watch list, snapshots and settings, created empty on your instance's first boot - nginx as the only network-facing service: HTTPS on port 443 with a self-signed certificate created on first boot, port 80 redirecting to HTTPS
- A per-instance login password and REST API key, generated on first boot into a root-only file
changedetection-set-hostname, a helper that serves changedetection.io under your own DNS name and re-issues the certificate for it
Default user: ubuntu · Architecture: x86_64 · Root volume: 8 GiB gp3 · Data volume: 20 GiB gp3
Security posture
- No default login. Upstream changedetection.io ships with no password at all. On this image the login password (28 characters) is generated on each instance's first boot and stored as changedetection.io's own salted PBKDF2 hash. The REST API key, the RSS token and the session-signing secret are created by changedetection.io itself on that first boot. None is in the image, so no two instances share one.
- Fails closed. changedetection.io will not start unless the datastore holds a password, the API key is switched on and the password file is root-only. nginx stays closed until first boot has proved that the password and the API key really authenticate.
- No secret in the container environment. The container's environment and command line carry only the listen address and proxy settings, so
docker inspectnever shows a secret. - No example watches. A new upstream datastore adds two example watches. First boot creates the datastore in a container with no network at all and removes them, so your instance fetches nothing until you add a watch.
- What is public. Everything except the
/healthzhealth check needs the login (web UI) or the API key (REST API). The optional "anonymous access to watch history" setting is off. - Only nginx faces the network. changedetection.io listens on
127.0.0.1:5000. nginx answers only for the configured hostname (any otherHostheader gets its connection closed), uses TLS 1.2 or 1.3, refuses cross-originPOSTrequests and cross-site WebSocket upgrades (403), strips CORS headers, rate-limits sign-in attempts, forces the session cookie toSecure,HttpOnlyandSameSite=Lax, and replaces any client-suppliedX-Forwarded-For,X-Real-IPorForwardedheader with the real peer address. - The hostname comes from configuration, never from a request. First boot reads it from EC2 instance metadata (IMDSv2). nginx hands changedetection.io that fixed name, so links and redirects it builds cannot be steered by a request.
- Watches cannot read instance metadata. A watch fetches any URL you or your users enter, so three layers stop it reaching the instance metadata service: changedetection.io's own URL filter refuses private, link-local and carrier-grade NAT addresses; a Docker
DOCKER-USERfirewall rule, in place before any container starts, drops container traffic to 169.254.169.254; and nginx, Docker and the changedetection units are blocked by systemdIPAddressDeny. This was proved at build time with real watches pointed at the metadata service. - Least-privilege container. changedetection.io runs as an unprivileged user (uid 10001) with every Linux capability dropped,
no-new-privileges, a 1 GiB memory limit and a process limit. - No scheduled firmware-metadata refresh. The
fwupdfirmware updater stays installed, but its LVFS metadata refresh (fwupd-refresh.timerand.service) is masked, so an LVFS outage never marks your instance unhealthy at boot; runsudo fwupdmgr refreshyourself if you use it. - No core dumps. changedetection.io, nginx and the first-boot units run with core dumps disabled, so process memory holding your credentials is never written to disk.
- IMDSv2 required. The AMI is registered with
ImdsSupport v2.0, so instances refuse token-less (IMDSv1) metadata requests. - Per-instance identity. SSH host keys, the machine ID, the TLS certificate and Docker's engine ID are generated fresh on every instance.
Prerequisites
- An AWS account and permission to launch EC2 instances
- An EC2 key pair in the Region you launch in
- Outbound internet access from the instance to the pages you want to watch and the services you notify
Recommended instance type: m5.large. changedetection.io's plain HTTP fetcher checks hundreds of watches comfortably on 2 vCPUs and 8 GiB of memory. The image also runs on 4 GiB instances. Grow the data volume if you keep long snapshot histories for many watches.
Connecting to your instance
| Version | Operating system | SSH user |
|---|---|---|
| changedetection.io 0.60.10 on Ubuntu 24.04 | Ubuntu 24.04 LTS | ubuntu |
Step 1: Launch the instance
Option A: AWS Marketplace console
- Open the cloudimg Hardened Actual Budget & Self-Hosted Personal Apps AMIs listing on AWS Marketplace and choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- Once the subscription is active, choose Launch your software.
- Choose One-click launch from AWS Marketplace (or Launch from EC2 Console for the full launch wizard). In Version, choose changedetection.io 0.60.10 on Ubuntu 24.04, then choose your Region.
- Pick an instance type (
m5.largerecommended; the software charge is $0.04 per vCPU per hour) and your key pair. - Security group: the listing's default group opens SSH (22), HTTP (80) and HTTPS (443). Restrict SSH to your own IP address, and 80 and 443 to the networks you work from.
- Choose your VPC and subnet, leave the metadata defaults (the image already requires IMDSv2), then choose Launch.
Option B: AWS CLI
Run this on your own workstation. It creates a security group that allows SSH from your management address and HTTPS from your own network, then launches the instance. Replace the AMI ID with the one shown for your Region on the Marketplace launch page, and the VPC, subnet, key pair and CIDR ranges with your own:
SG=$(aws ec2 create-security-group --group-name changedetection \
--description "changedetection.io" --vpc-id vpc-0123456789abcdef0 \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 443 --cidr 203.0.113.0/24
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 80 --cidr 203.0.113.0/24
aws ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type m5.large \
--key-name my-key-pair \
--security-group-ids "$SG" \
--subnet-id subnet-0123456789abcdef0 \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=changedetection}]'
You do not need --metadata-options or --block-device-mappings: the AMI already requires IMDSv2 and brings its 20 GiB data volume. If you want the address to survive a stop and start, associate an Elastic IP.
Step 2: Connect and find your login
Connect as ubuntu with the key pair you chose:
ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip>
First boot finishes within a minute or two of launch. It sets the hostname changedetection.io is served as (the instance's public DNS name when it has one), creates the TLS certificate for it, generates the login password, lets changedetection.io create its datastore, proves the password and the API key work, and writes /root/changedetection-credentials.txt (mode 0600, root). Only then does it open nginx. These are the entries in that file (the secrets are not shown here):
sudo grep -E '^CHANGEDETECTION_URL=' /root/changedetection-credentials.txt
sudo grep -oE '^[A-Z_]+=' /root/changedetection-credentials.txt
sudo stat -c '%a %U:%G' /root/changedetection-credentials.txt
CHANGEDETECTION_URL=https://ec2-3-83-154-156.compute-1.amazonaws.com/
CHANGEDETECTION_URL=
CHANGEDETECTION_PASSWORD=
CHANGEDETECTION_API_KEY=
600 root:root
| Value | What it is for |
|---|---|
CHANGEDETECTION_URL |
The address of this instance's changedetection.io |
CHANGEDETECTION_PASSWORD |
Login password, generated on this instance (changedetection.io has no username) |
CHANGEDETECTION_API_KEY |
REST API key (send it as the x-api-key header), created on this instance |
To read the values, run sudo cat /root/changedetection-credentials.txt in your own session and store them in your password manager.
Step 3: Confirm changedetection.io is running
Docker, the metadata firewall rule, changedetection.io and nginx are active. Only sshd and nginx listen beyond loopback: changedetection.io is on 127.0.0.1:5000, and its data is on the separate EBS volume.
systemctl is-active docker.service cloudimg-docker-imds-deny.service changedetection.service nginx.service
sudo docker ps --format '{{.Names}} {{.Image}} {{.Ports}}' | sed -E 's/@sha256:[0-9a-f]+//'
sudo ss -Htlnp | awk '{print $4, $6}' | sed -E 's/,pid=[0-9]+,fd=[0-9]+//g' | sort -u
findmnt -no SOURCE,FSTYPE,SIZE /var/lib/changedetection
curl -sk https://127.0.0.1/healthz
active
active
active
active
changedetection ghcr.io/dgtlmoon/changedetection.io:0.60.10 127.0.0.1:5000->5000/tcp
0.0.0.0:22 users:(("sshd"),("systemd"))
0.0.0.0:443 users:(("nginx"),("nginx"),("nginx"))
0.0.0.0:80 users:(("nginx"),("nginx"),("nginx"))
127.0.0.1:5000 users:(("docker-proxy"))
127.0.0.53%lo:53 users:(("systemd-resolve"))
127.0.0.54:53 users:(("systemd-resolve"))
[::]:22 users:(("sshd"),("systemd"))
[::]:443 users:(("nginx"),("nginx"),("nginx"))
[::]:80 users:(("nginx"),("nginx"),("nginx"))
/dev/nvme1n1 ext4 19.5G
ok
/healthz is answered by nginx itself and reveals nothing, so it is safe to use as a load balancer health check.
Step 4: Prove the login and the web defaults
A signed-out visit to the watch list is redirected to the sign-in page, and the REST API refuses requests without the key or with a wrong one. A cross-origin POST is refused, an unknown Host header gets no answer, and plain HTTP redirects to HTTPS. The last line uses this instance's own API key and shows the version and an empty watch list:
H=$(sudo sed -n 's/^set \$app_host "\(.*\)";$/\1/p' /etc/nginx/changedetection-host.conf)
R=(--resolve "$H:443:127.0.0.1")
K=$(sudo grep '^CHANGEDETECTION_API_KEY=' /root/changedetection-credentials.txt | cut -d= -f2-)
api() { curl -sk "${R[@]}" -H @<(printf 'x-api-key: %s\n' "$K") "$@"; }
curl -sk "${R[@]}" -o /dev/null -w 'watch list, signed out -> %{http_code}\n' "https://$H/"
curl -sk "${R[@]}" -o /dev/null -w 'API, no key -> %{http_code}\n' "https://$H/api/v1/watch"
curl -sk "${R[@]}" -o /dev/null -w 'API, wrong key -> %{http_code}\n' -H 'x-api-key: not-the-real-key' "https://$H/api/v1/watch"
curl -sk "${R[@]}" -o /dev/null -w 'cross-origin POST -> %{http_code}\n' -X POST -H 'Origin: https://evil.example' -d 'password=x' "https://$H/login"
curl -sk -o /dev/null -w 'unknown Host -> %{http_code}\n' -H 'Host: evil.example' https://127.0.0.1/ || true
curl -s -o /dev/null -w 'http -> %{http_code}\n' -H "Host: $H" http://127.0.0.1/
api "https://$H/api/v1/systeminfo" | jq -c '{version, watch_count, tag_count}'
watch list, signed out -> 302
API, no key -> 403
API, wrong key -> 403
cross-origin POST -> 403
unknown Host -> 000
http -> 301
{"version":"0.60.10","watch_count":0,"tag_count":0}
The API key reaches curl through a header file on a file descriptor (-H @<(...)), so it never appears on a command line or in your shell history.
Step 5: Sign in to changedetection.io
Browse to CHANGEDETECTION_URL (https://<public-dns-name>/). The certificate is self-signed, so your browser warns once; accept it for this host, or install your own certificate (Step 8). changedetection.io has a single password and no username. Enter CHANGEDETECTION_PASSWORD:

A new instance has no watches, no tags and no notification targets.
Step 6: Add watches
Paste a URL into Web page URL and choose Watch, or use the REST API. This adds three watches with the default check interval and lists them once their first check has run:
H=$(sudo sed -n 's/^set \$app_host "\(.*\)";$/\1/p' /etc/nginx/changedetection-host.conf)
R=(--resolve "$H:443:127.0.0.1")
K=$(sudo grep '^CHANGEDETECTION_API_KEY=' /root/changedetection-credentials.txt | cut -d= -f2-)
api() { curl -sk "${R[@]}" -H @<(printf 'x-api-key: %s\n' "$K") "$@"; }
for site in https://www.cloudimg.co.uk/ https://changedetection.io/CHANGELOG.txt https://www.debian.org/News/; do
jq -nc --arg u "$site" '{url:$u}' |
api -H 'Content-Type: application/json' \
--data-binary @- "https://$H/api/v1/watch" | jq -e '.uuid' >/dev/null && echo "watching $site"
done
sleep 60
api "https://$H/api/v1/watch" |
jq -r 'to_entries[] | "\(.value.url) checked=\(.value.last_checked > 0) error=\(.value.last_error)"' | sort
watching https://www.cloudimg.co.uk/
watching https://changedetection.io/CHANGELOG.txt
watching https://www.debian.org/News/
https://changedetection.io/CHANGELOG.txt checked=true error=false
https://www.cloudimg.co.uk/ checked=true error=false
https://www.debian.org/News/ checked=true error=false
The watch list shows each page's title, when it was last checked and when it last changed:

Preview shows the text of the latest snapshot. Once a page changes, History highlights what was added and removed between any two snapshots:

changedetection.io fetches from this instance, so a watch reaches whatever the instance can reach on the public internet. Private, link-local and carrier-grade NAT addresses, including the instance metadata service, are refused on purpose.
Step 7: Check the security controls
A watch pointed at the instance metadata service is refused by changedetection.io's URL filter (and is then deleted). The certificate was made for this instance on first boot, container traffic to the metadata service is dropped, nginx, Docker and the changedetection unit are denied it as well, the container runs as an unprivileged user with no capabilities, and the password file and datastore are closed to other users:
H=$(sudo sed -n 's/^set \$app_host "\(.*\)";$/\1/p' /etc/nginx/changedetection-host.conf)
R=(--resolve "$H:443:127.0.0.1")
K=$(sudo grep '^CHANGEDETECTION_API_KEY=' /root/changedetection-credentials.txt | cut -d= -f2-)
api() { curl -sk "${R[@]}" -H @<(printf 'x-api-key: %s\n' "$K") "$@"; }
U=$(jq -nc '{url:"http://169.254.169.254/latest/meta-data/"}' | api -H 'Content-Type: application/json' --data-binary @- "https://$H/api/v1/watch" | jq -r .uuid)
api -o /dev/null "https://$H/api/v1/watch/$U?recheck=1"; sleep 20
api "https://$H/api/v1/watch/$U" | jq -r '.last_error' | cut -c1-110
api -o /dev/null -w 'metadata watch deleted -> %{http_code}\n' -X DELETE "https://$H/api/v1/watch/$U"
openssl s_client -connect 127.0.0.1:443 -servername "$H" </dev/null 2>/dev/null | openssl x509 -noout -subject -enddate
sudo iptables -S DOCKER-USER | grep DROP
systemctl show -p IPAddressDeny --value nginx.service docker.service changedetection.service
sudo docker inspect --format '{{.Name}} user={{.Config.User}} cap_drop={{.HostConfig.CapDrop}} no_new_privs={{.HostConfig.SecurityOpt}}' changedetection
sudo stat -c '%a %U:%G %n' /opt/changedetection/secrets/admin_password /var/lib/changedetection
Exception: Fetch blocked: 'http://169.254.169.254/latest/meta-data/' resolves to a private/reserved IP address
metadata watch deleted -> 204
subject=CN = ec2-3-83-154-156.compute-1.amazonaws.com, O = cloudimg
notAfter=Feb 9 09:30:43 2029 GMT
-A DOCKER-USER -d 168.63.129.16/32 -j DROP
-A DOCKER-USER -d 169.254.169.254/32 -j DROP
168.63.129.16/32 169.254.169.254/32
168.63.129.16/32 169.254.169.254/32
168.63.129.16/32 169.254.169.254/32
/changedetection user=10001:10001 cap_drop=[ALL] no_new_privs=[no-new-privileges:true]
600 root:root /opt/changedetection/secrets/admin_password
700 UNKNOWN:UNKNOWN /var/lib/changedetection
The datastore belongs to uid 10001, the container's user, which has no account on the host (hence UNKNOWN).
Step 8: Serve changedetection.io under your own DNS name
Point a DNS record at the instance (an Elastic IP keeps the address stable), then tell changedetection.io the name. The helper re-issues the self-signed certificate for it, updates nginx's accepted names and the cross-origin guard, records the name in /etc/cloudimg/changedetection-hostname (kept across reboots) and updates CHANGEDETECTION_URL:
sudo changedetection-set-hostname watch.example.com
changedetection-set-hostname: changedetection.io is served at https://watch.example.com/
Until you set a name, the instance follows its own public DNS name: if it comes back from a stop and start with a new address, the certificate and nginx names are updated at boot.
To use a certificate from your own CA or from Let's Encrypt instead, replace /etc/ssl/changedetection/changedetection.crt and /etc/ssl/changedetection/changedetection.key (keep the key mode 0600, owner root), set the name with changedetection-set-hostname first so the automatic refresh leaves your certificate alone, and run sudo systemctl reload nginx.
Step 9: Notifications and settings
Open Settings to change the default check interval, global filters, fetching options and time zone, and to add notification targets. Settings > Notifications (and each watch's Edit > Notifications) take Apprise URLs, for example mailtos://user:password@smtp.example.com, slack://TokenA/TokenB/TokenC/#channel or json://hooks.example.com/changes; Send test notification checks the target straight away. The full list of services is in the Apprise documentation.

Keep the password. The Remove password button would leave the instance open to anyone who can reach it. This image refuses to start changedetection.io without a password, so after a restart the service stays down until you run sudo changedetection-reset-password (see Troubleshooting).
Step 10: Change the password or API key
changedetection.io's Settings page only offers Remove password while a password is set, so change the password from the shell instead, without ever opening the instance. Put the new password (at least 20 characters) into the root-only first-boot password file and re-seed it; changedetection-reset-password stores it as changedetection.io's salted hash and restarts the service:
sudo nano /opt/changedetection/secrets/admin_password
sudo changedetection-reset-password
Keep the file at mode 0600, owner root. To issue a new API key, use Settings > API > Regenerate API key and keep API access switched on. Then update /root/changedetection-credentials.txt with sudo nano (or delete that file) so it does not hold stale secrets.
Maintenance
- Logs:
sudo docker logs changedetectionfor changedetection.io,/var/log/nginx/for HTTPS access,sudo journalctl -u changedetection-firstboot.service -u changedetection-postboot.servicefor first boot. - Restart:
sudo systemctl restart changedetection, orsudo systemctl restart nginx. - Backups: Settings > Backups creates a zip of your watches, snapshots and settings for download. For the whole datastore, take EBS snapshots of the data volume mounted at
/var/lib/changedetection. - Operating system updates: Ubuntu's unattended security upgrades stay on, so nginx, Docker and the rest of the OS keep receiving patches. Apply the rest with
sudo apt update && sudo apt upgradein a maintenance window. - Upgrading changedetection.io: the image is pinned by digest and first boot never pulls. To upgrade deliberately, read the release notes, take a backup, pull the new release by digest (
sudo docker pull ghcr.io/dgtlmoon/changedetection.io:<new-version>@sha256:<digest>), change theimage:line in/opt/changedetection/docker-compose.ymlto it and runsudo systemctl restart changedetection. Keep theuser,cap_drop,security_opt,ulimitsand loopbackportssettings: never publish port 5000 on all interfaces and never use host networking, which would bypass both nginx and the metadata firewall rule.
Troubleshooting
The sign-in page says the password is wrong. Use CHANGEDETECTION_PASSWORD from /root/changedetection-credentials.txt exactly, over https://. After several quick failures nginx answers 429 for a short while; wait a minute and try again.
nginx is not running. On a new instance it waits until first boot has proved the credentials, by design. Check with sudo journalctl -u changedetection-postboot.service --no-pager | tail -20 and sudo systemctl status changedetection.
changedetection.io does not start. sudo journalctl -u changedetection --no-pager | tail -20 shows the start-up check's reason, for example no password in the datastore after Remove password was pressed in Settings, or API access switched off. sudo changedetection-reset-password restores the password held in /opt/changedetection/secrets/admin_password (the first-boot password, unless you changed it in Step 10), switches API access back on and starts changedetection.io again.
A watch shows "Fetch blocked". The URL resolves to a private, link-local or carrier-grade NAT address, which changedetection.io refuses by design. Watches of addresses inside your VPC are not supported on this image.
Support
cloudimg provides 24/7 support for this image by email and live chat: launching and connecting, watches and notifications, TLS and hostnames, and upgrades.
changedetection.io is developed by Leigh Morresi (dgtlmoon) and contributors and is released under the Apache License 2.0. The licence text and a pointer to the source ship on the instance in /usr/share/doc/changedetection/. Ubuntu is a registered trademark of Canonical Ltd. Docker is a trademark of Docker, Inc. All other product and company names are trademarks or registered trademarks of their respective holders. cloudimg is not affiliated with or endorsed by them.