DEV Community

Cover image for My static site's visitor counter reset to 1. Here is the deploy checklist I wish I had
Zihang Dong 董子航
Zihang Dong 董子航

Posted on

My static site's visitor counter reset to 1. Here is the deploy checklist I wish I had

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

There is one small exception:

public/data/visits.json
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Build a package locally.
  2. Upload it to the server.
  3. Back up the live site.
  4. Remove the old public directory.
  5. Extract the new package.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then after the next deploy, I checked the file directly:

cat /www/wwwroot/24picture.com/public/data/visits.json
Enter fullscreen mode Exit fullscreen mode

And checked the public endpoint:

https://example.com/api/visit.php
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)