Why I wrote this: When I edit aijsons.com, every page uses absolute paths like /css/styles.css and /js/navbar.js. Opening index.html directly in a browser breaks navigation. Live Server and python -m http.server fix that — but they do not simulate Cloudflare redirect rules, trailing-slash normalization, or the security headers in our _headers file. I pushed a blog card link to a slug that existed only in sitemap-blog.xml, not on disk, and nothing failed locally because I never clicked that card. Docker gave me a reproducible local environment that behaves enough like production to catch those mistakes before git push.

What Local Preview Must Simulate

A static site on GitHub Pages + Cloudflare is not just HTML files in a folder. For aijsons.com, production behavior includes:

  • Directory URLs with trailing slashes/blog/ not /blog
  • Content redirects — merged tool pages like /tools/json-diff//tools/json-compare/
  • Security headersX-Content-Type-Options, Referrer-Policy, etc. from _headers
  • Correct MIME typesads.txt must serve as text/plain, not text/html
  • SPA-style tool pages — client-side JS that assumes it runs under /tools/json-formatter/, not file:///D:/...

A bare static file server satisfies only the last item partially. That gap is why I run nginx inside Docker for final manual QA.

Why Not Just Use Live Server?

VS Code Live Server is excellent for iterative CSS work. I still use it daily. But it cannot:

  • Parse Cloudflare _redirects syntax (301 chains, splat patterns, 200! force rules)
  • Apply per-path cache and security headers from _headers
  • Simulate the apex → www redirect at the DNS edge
  • Run the same port and hostname assumptions as production (www.aijsons.com)

Real incident: I tested a redirect rule with pattern /tools/json-diff (no trailing slash). Cloudflare Pages matched paths differently than I expected; the live site returned 404 while my local Live Server test looked fine because I manually navigated to the destination URL. A local nginx config that mirrors the redirect table would have caught it immediately.

The Docker Setup

Two files in the repo root are enough. I keep them in a docker/ folder so they do not clutter the GitHub Pages root.

docker-compose.yml

services:
  preview:
    image: nginx:1.25-alpine
    ports:
      - "8080:80"
    volumes:
      - ..:/usr/share/nginx/html:ro
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./redirects.map:/etc/nginx/redirects.map:ro

nginx.conf (minimal production-like behavior)

worker_processes 1;
events { worker_connections 256; }

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    map_hash_bucket_size 128;

    map $request_uri $redirect_target {
        include /etc/nginx/redirects.map;
        default "";
    }

    server {
        listen 80;
        server_name localhost;
        root /usr/share/nginx/html;
        index index.html;

        # Security headers (subset of _headers)
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options SAMEORIGIN always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;

        # ads.txt content type
        location = /ads.txt {
            default_type text/plain;
        }

        # Apply redirect map
        if ($redirect_target != "") {
            return 301 $redirect_target;
        }

        # Trailing slash for directories
        location ~ ^(/[^.]*[^/])$ {
            try_files $uri $uri/ $uri/index.html @slash;
        }
        location @slash {
            return 301 $uri/;
        }

        location / {
            try_files $uri $uri/ $uri/index.html =404;
        }
    }
}

redirects.map (maintained manually from _redirects)

I translate the highest-risk rules from _redirects into nginx map entries — not all 90+ rules, just the ones I am actively changing:

"/tools/json-diff/"   "/tools/json-compare/";
"/tools/json-diff"      "/tools/json-compare/";
"/blog/json-formatter-guide-developers/foo/" "/blog/json-formatter-complete-guide-2026/";

Full automation is possible (a script that converts Cloudflare syntax to nginx), but for a solo-maintainer site, translating 5–10 rules before each deploy takes two minutes and prevents the most common regressions.

Running the Preview

cd docker
docker compose up

# Open in browser
# http://localhost:8080/
# http://localhost:8080/tools/json-formatter/
# http://localhost:8080/tools/json-diff/   → should 301 to json-compare

Verify redirects with curl the same way I test production:

curl -sI http://localhost:8080/tools/json-diff/ | findstr /i "HTTP location"
curl -sI http://localhost:8080/ads.txt | findstr /i "content-type"

On Linux/macOS, swap findstr for grep -i.

Three-Layer Local Workflow

I do not replace Live Server — I stack three layers with different jobs:

LayerToolPurpose
1. Fast editLive Server / python -m http.serverCSS, tool UI, instant reload
2. Structure checkGitHub Actions CISitemap ↔ disk, XML validity, redirect count — see our CI guide
3. Pre-push QADocker + nginxRedirects, headers, directory URLs, ads.txt MIME

Layer 2 catches missing files. Layer 3 catches routing behavior. Together they prevented a repeat of the sitemap/blog-index drift incident described in our deployment guide.

What Docker Preview Still Cannot Simulate

Be honest about limits — overstating local parity creates false confidence:

  • Cloudflare Worker trailing-slash logic (worker-redirect-v2.js) runs at the edge, not in nginx. I still verify Worker changes in Cloudflare's dashboard preview or staging route.
  • Cache behaviorCache-Control: immutable on JS/CSS only shows up in DevTools after a real Cloudflare fetch.
  • GitHub Pages build quirks — Jekyll is disabled via .nojekyll, so this is a non-issue for us, but other static hosts may inject build steps.
  • AdSense ad slots — Google does not serve real ads on localhost; I only verify the ad script loads without console errors.

Alternative: Caddy with the Cloudflare Adapter

If you already use Caddy, the cloudflare snippet community projects parse a subset of _redirects directly. For aijsons.com I stayed on nginx because the map directive is explicit — when a redirect fails, I can read one file and know exactly what nginx evaluated. Fewer magic layers means faster debugging at 11pm before a deploy.

Checklist Before Every Deploy

  1. Run CI locally or wait for GitHub Actions green on the PR
  2. docker compose up — click every new blog card and tool link added in this commit
  3. curl -sI on any new or changed redirect rule
  4. Confirm /ads.txt returns text/plain with publisher ID intact
  5. Check browser console on one tool page — no 404s for /js/ or /css/ assets

This checklist takes about eight minutes. The deploy that skips it is the one that sends me back to Search Console fixing crawl errors.

Key Takeaways

  • Absolute-path static sites need an HTTP server locally — never rely on file:// testing.
  • Live Server is for speed; Docker + nginx is for production-like routing and headers.
  • Maintain a small redirects.map synced with your highest-risk _redirects rules.
  • Pair local Docker preview with CI sitemap checks — structure and behavior are different failure modes.
  • Know what local preview cannot simulate (Cloudflare Workers, edge cache, real ads).