A few hours after a successful deploy, I opened my own site and saw the kind of number that makes you immediately stop doing everything else:
Total visits: 1
That was not a new project. It was not a fresh database. It was not supposed to say 1.
For context, I have been building 24Picture, a small browser-local image toolkit. It is mostly static HTML, CSS, JavaScript, Canvas, and a tiny PHP endpoint for visit stats. The image tools themselves do not upload files anywhere. The site is intentionally boring: static pages, shared scripts, cache-busted assets, and a full-package deploy script.
The deploy worked. The pages loaded. The new homepage shipped.
And the visitor counter reset itself.
This is the checklist I wish I had before that happened.
The setup: static site, one tiny runtime file
Most of the site is just files on disk:
public/
index.html
tools/*.html
assets/js/*.js
assets/css/*.css
service-worker.js
There is one small exception:
public/data/visits.json
A PHP endpoint reads and writes that file:
$dataFile = __DIR__ . '/../data/visits.json';
if (!file_exists($dataFile)) {
file_put_contents($dataFile, json_encode([
'total' => 0,
'today' => 0,
'date' => date('Y-m-d'),
'start_date' => '2026-05-01'
]));
}
$data = json_decode(file_get_contents($dataFile), true);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data['total']++;
$data['today']++;
file_put_contents($dataFile, json_encode($data));
}
That is fine for a tiny site. It is not pretending to be a serious analytics platform. It just gives me a basic heartbeat without shipping a large third-party analytics script.
But it also means visits.json is not source code.
It is runtime data.
And my deploy script forgot that distinction.
The bug: full replacement treated runtime data like build output
The original deploy process was simple:
- Build a package locally.
- Upload it to the server.
- Back up the live site.
- Remove the old public directory.
- Extract the new package.
- Fix ownership and permissions.
The dangerous part looked conceptually like this:
cp -a "$SITE_DIR" "$BACKUP_DIR"
rm -rf "$SITE_DIR/public" "$SITE_DIR/config" "$SITE_DIR/deploy"
tar -xzf "$PACKAGE_PATH" -C "$SITE_DIR"
chown -R www:www "$SITE_DIR" || true
That is a common pattern for small static sites. It keeps production from accumulating stale files. It makes deploys predictable. It avoids weird cases where a deleted local file keeps surviving online forever.
But it also deleted this:
$SITE_DIR/public/data/visits.json
On the next request, the PHP endpoint did exactly what it was written to do: it saw a missing file, recreated it from zero, and then incremented it.
So production proudly reported:
{"total":1,"today":1}
The code was not broken. The deploy model was incomplete.
Fix #1: restore from the highest trustworthy backup
The first step was not changing code. It was recovering the most trustworthy data.
Because the deploy script created full backups before replacing the site, I could inspect older backup directories and compare their public/data/visits.json files.
The highest trustworthy pre-reset value was:
{
"total": 175,
"today": 7,
"date": "2026-05-28",
"start_date": "2026-05-01"
}
I restored that exact JSON to production, fixed ownership, and verified the API returned the expected value.
The important detail: do not restore from the newest backup blindly.
After a reset happens, later backups may contain the already-corrupted value. In my case, some newer backups contained reset-derived counts. The right source was the newest backup from before the reset, not simply the latest backup on disk.
Fix #2: preserve runtime data during full deploys
The actual deploy fix was small.
After creating the backup, I record the old runtime data path:
cp -a "$SITE_DIR" "$BACKUP_DIR"
printf '%s' "$BACKUP_DIR" > "$LAST_BACKUP_FILE"
RUNTIME_DATA_BACKUP="${BACKUP_DIR}/public/data"
Then I still do the full replacement:
rm -rf "$SITE_DIR/public" "$SITE_DIR/config" "$SITE_DIR/README.md" "$SITE_DIR/.gitignore" "$SITE_DIR/deploy"
tar -xzf "$PACKAGE_PATH" -C "$SITE_DIR"
But immediately after extraction, I copy the runtime data back:
if [ -d "$RUNTIME_DATA_BACKUP" ]; then
mkdir -p "$SITE_DIR/public/data"
cp -a "$RUNTIME_DATA_BACKUP/." "$SITE_DIR/public/data/"
fi
chown -R www:www "$SITE_DIR" || true
Now full deploys still remove stale build output, but they no longer erase production-owned state.
That is the rule I should have written down earlier:
Full deploys may replace build artifacts. They must not replace runtime data unless that is an explicit migration.
Fix #3: make the deploy script prove it is safe
A deploy script is production code. It deserves at least a syntax check.
Before trusting the new version, I ran:
bash -n /root/server-deploy.sh
Then after the next deploy, I checked the file directly:
cat /www/wwwroot/24picture.com/public/data/visits.json
And checked the public endpoint:
https://example.com/api/visit.php
The post-deploy rule is now simple:
If total or today unexpectedly returns to 0 or 1, stop.
Do not keep deploying.
Check runtime data before touching anything else.
That sounds obvious after the incident. It was not in my checklist before the incident.
The cache lesson from the same release
The same release also touched the homepage and language switching. That created a different kind of deployment risk: cache visibility.
A user clicking ?lang=zh but still seeing English is not a small i18n bug from their point of view. It looks like the site is ignoring them.
On a static site with a Service Worker, cache-busting needs to be specific. I ended up treating assets differently based on where they live:
- Shell-level assets need a Service Worker cache version bump.
- Shared JavaScript files need changed query strings in the HTML that references them.
- CSS-only hotfixes can often be isolated to the affected page or stylesheet reference.
- Shared data files need every consuming page to reference the new version if you want the update visible immediately.
For example:
<script src="/assets/js/i18n.js?v=1.8.14"></script>
<link rel="stylesheet" href="/assets/css/main-new.css?v=1.8.15">
<script src="/assets/js/changelog-data.js?v=1.8.15"></script>
I wrote a short public changelog entry for the release because this kind of tiny infrastructure work is easy to forget. The code change may be one line, but the operational lesson is much bigger.
My new tiny-site deploy checklist
Here is the checklist I am using now.
1. Classify every path before deleting anything
Before a full replacement deploy, split files into categories:
build artifacts:
public/*.html
public/assets/**
public/tools/**
runtime data:
public/data/visits.json
uploads/
cache/
queues/
generated reports
anything written by production code
secrets/config:
.env
config/*.php
server-only keys
If a production process writes it, the deploy script should not casually delete it.
2. Preserve runtime data explicitly
Do not rely on “it is probably in the package” or “the backup exists somewhere.”
Make the restore step part of the deploy script:
RUNTIME_DATA_BACKUP="${BACKUP_DIR}/public/data"
# after extracting the new package
if [ -d "$RUNTIME_DATA_BACKUP" ]; then
mkdir -p "$SITE_DIR/public/data"
cp -a "$RUNTIME_DATA_BACKUP/." "$SITE_DIR/public/data/"
fi
Explicit beats remembered.
3. Verify the values users can see
Do not only check whether the deploy command exited successfully.
Check the thing the user will notice:
Homepage loads
Language switch renders the expected language
Changelog shows the newest version
Visitor stats did not reset
Critical assets use the new cache-busted URL
A deploy that returns DEPLOY_OK can still ship a bad state.
4. Keep one small audit script per scary class of bug
I now prefer small, boring verification scripts over trying to remember every edge case manually.
Examples:
verify homepage i18n keys
scan changelog asset versions
check broken internal links
check missing hreflang tags
scan for mojibake signatures
verify visit stats after deploy
The best audit scripts are not clever. They are just executable memory.
5. Write the postmortem while it is still embarrassing
This is the part I recommend most.
When the bug still feels annoying, write down:
- what happened
- why it happened
- what file caused it
- what exact check would have caught it
- what rule changes now
A week later, you will remember “the stats bug.” You may not remember the exact path, command, backup, and prevention rule.
Future-you needs the boring details.
A small ask
One practical problem with small independent tools is discovery. Search engines trust links, and new projects do not have many.
If you maintain a list of privacy-friendly tools, browser-based utilities, frontend resources, or “no upload” image tools, and you think this project belongs there, a mention or backlink to 24Picture's zero-upload image tools would genuinely help.
No pressure, and please do not link it if it is not useful for your audience. But if it fits, that kind of independent web curation is still one of the best ways small tools get found.
I am also happy to look at useful directories, resource lists, or indie tool pages in return. Good lists deserve links too.
Wrap
The bug was small. One JSON file disappeared.
But the lesson was bigger: static deploys are only simple if everything is actually static.
The moment a production process writes a file, even a tiny one, your deploy script has to treat it as state. If it does not, the script is not just deploying your site. It is editing your production data model.
And sometimes the first sign is a very loud number:
1
Top comments (0)