← Back to Wiki
Hosting / Migrations

Moving a Static Site to a New Host, and the One File That Was Not Static

Moving static sites off a homelab and onto a small VPS is a good idea. They stop depending on your power, your ISP and your maintenance windows. It is also a copy of some files, which is why it is easy to do without checking, and why a site that has been "static" for a year can have one file that is not.

Share on X

Checksum every file, do not compare by eye

The whole finding came from one command, run because it was cheap rather than because anything looked wrong:

ssh old "cd /srv/site && find . -type f -exec sha256sum {} + | sort" > /tmp/old.txt
ssh new "cd /srv/site && find . -type f -exec sha256sum {} + | sort" > /tmp/new.txt
diff /tmp/old.txt /tmp/new.txt

158 files matched byte for byte. One did not: a status.json that a page rendered as live service status, regenerated by a cron job every two minutes on the old host. Copy the directory and you copy a snapshot that never updates again, and nothing errors. The page just shows increasingly old information while looking completely fine.

BE WARNED: a generated file looks exactly like a static one. It sits in the same directory, has a normal name, and is served the same way. The thing that makes it dynamic lives somewhere else entirely, in a crontab or a timer you did not think to read. Before any content migration, list what writes into the web root:
crontab -l; ls /etc/cron.d/; systemctl list-timers --all
find /srv/site -mmin -60        # anything modified in the last hour is a clue

When the generator cannot come with you

The obvious fix is to move the cron job too. Ours could not move, for a reason worth stating: it polls an internal monitoring system using stored credentials. Neither the internal address nor the credentials belong on a public box, and moving them would trade a small annoyance for a real exposure.

The other obvious fix is to push from home after each run. That failed too, because SSH from the home network to this provider is blocked, consistently, for reasons never established.

So invert it: let the new host pull

A timer on the public host fetches the generated file from the origin on the same cadence the generator runs. No inbound access to the home network, no credentials on the public box, and the direction of trust matches the direction of risk.

The important part is what it does with a bad response:

tmp=$(mktemp)
curl -fsS --max-time 10 -H "Host: sonofatech.com" "https://$ORIGIN_IP/status.json" -o "$tmp" || exit 0
[ -s "$tmp" ] || exit 0                 # empty? keep what we have
jq -e . "$tmp" >/dev/null 2>&1 || exit 0 # not valid JSON? keep what we have
install -m 644 "$tmp" /srv/site/status.json

An error page, a truncated download or an origin that is down all leave the last good copy in place. Stale data is a much better failure than an empty file or a captive-portal page rendered as your status board.

Zero-gap TLS: bring the certificates with you

Do not plan to issue new certificates at cutover. That puts an ACME challenge, DNS propagation and your users all in the same five minutes.

Both of ours were already in hand. One site sits behind a CDN and uses that CDN's origin certificate, which is valid for years and is exactly what the CDN expects. For the other, we copied the existing certificates out of the old server's storage into the new one's, so TLS was valid the instant DNS moved and the new server took over renewal afterwards.

Verify without -k. Turning off verification to "check the site loads" verifies the one thing you most need to be sure about.

Prove it serves before you touch DNS

You can test the entire new host on its real hostnames, over the real path, while the world still sees the old one:

curl -sS --resolve sonofatech.com:443:203.0.113.10 https://sonofatech.com/ -o /dev/null \
     -w '%{http_code} ssl_verify_result=%{ssl_verify_result}\n'

Check every hostname, not just the apex, and check any API path that proxies elsewhere. Then move DNS knowing the answer.

Two things that will still bite you afterwards

A provider diagnostic worth keeping

Our first attempt was on a different provider, where instance creation hung for ninety seconds with nothing recorded, while reads returned in 0.2 seconds and a deliberately invalid payload was rejected in 0.4.

That shape is diagnostic. Validation failing instantly while the real operation hangs means your token, network, payload and permissions are all fine, and the provider is not. Check the status page before you rewrite anything. Theirs confirmed it, and the migration moved to another provider that afternoon.

When this isn't your problem