DevOps

Deploy a Static Site: Netlify, GitHub Pages, and Nginx Step by Step

BudhiWorks itself is a static site. Here is how I deploy the same kind of project to Netlify, GitHub Pages, or a plain Nginx box—without mystery 404s and stale caches.

What you will learn

  • Static hosting is a publish-directory problem plus HTTPS and cache policy.
  • Netlify.toml (or equivalent) keeps headers and publish settings in git.
  • GitHub Pages project URLs need correct base paths for assets.
  • On a VPS, rsync --delete and Certbot plus clear Nginx cache rules.
  • Cache hashed assets forever; revalidate HTML so references stay fresh.
  • Verify deep links, HTTPS, and asset URLs after every first deploy to a new host.

What “static” buys you

A static site is HTML, CSS, JS, and assets on a CDN or web server. No app server process to babysit. Deploys are file syncs. That simplicity is why blogs, docs, and marketing sites should stay static until they truly need a backend.

Your job is correct URLs, HTTPS, cache headers, and a pipeline that publishes only the intended directory. Most outages I see are wrong publish directories, missing redirects for SPA routes, or CDN caches that never expire after a bad release.

Static does not mean “no pipeline.” Even hand-written HTML benefits from link checkers and HTML validators in CI before publish.

Option A: Netlify

Connect the Git repo, set the publish directory (for this project that might be the repo root or a dist/ folder), and leave the build command empty if there is no build step. For a generator, set build and publish accordingly.

Add a netlify.toml so settings live in git. Force HTTPS. Use _redirects or the toml redirects table for clean URLs. Preview deploys on pull requests catch broken links before production.

# netlify.toml
[build]
  publish = "."
  # command = "npm run build"  # if you have a build

[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "DENY"
    X-Content-Type-Options = "nosniff"

[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "/*.html"
  [headers.values]
    Cache-Control = "public, max-age=0, must-revalidate"

Option B: GitHub Pages

Pages can publish from a branch (often gh-pages) or from GitHub Actions. I prefer Actions: build on main, upload artifacts, deploy with the official Pages action. That keeps source and published artifacts separate.

Project sites live at username.github.io/repo/. Asset paths must respect that base. Absolute paths starting at / break on project pages. Relative paths or a configured base URL fix it.

# .github/workflows/pages.yml
name: Deploy Pages
on:
  push:
    branches: [main]
permissions:
  contents: read
  pages: write
  id-token: write
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/configure-pages@v4
      - uses: actions/upload-pages-artifact@v3
        with:
          path: .
      - id: deployment
        uses: actions/deploy-pages@v4

Option C: Nginx on a VPS

For full control I rsync the site to /var/www/site and point Nginx at it. Certbot handles Let’s Encrypt certificates. This path costs more ops time and gives you exact cache and header behavior.

rsync with --delete prevents old articles from lingering after renames. I learned that when a deleted draft kept ranking in search because the file never left the server.

server {
  listen 443 ssl http2;
  server_name example.com www.example.com;

  root /var/www/site;
  index index.html;

  location / {
    try_files $uri $uri/ =404;
  }

  location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|webp|woff2)$ {
    expires 30d;
    add_header Cache-Control "public";
  }

  location ~* \.html$ {
    add_header Cache-Control "no-cache";
  }
}

# Deploy
rsync -avz --delete ./site/ user@server:/var/www/site/

Caching and cache-busting

Hash filenames for CSS/JS when you have a build step, then cache them forever. HTML should revalidate so users pick up new asset references. Without that split, you either break users with stale HTML pointing at missing hashed files, or you never cache and waste bandwidth.

After a bad deploy on a CDN, purge intentionally. Hoping “it will expire” during an incident is how you spend an hour explaining why half the audience sees the old homepage.

Version the deploy. A simple GENERATED_AT or git SHA in a meta tag makes “what’s live?” answerable without SSH.

Checks before you call it live

Hit the homepage, an article URL, and a known 404. Confirm HTTPS redirects. View source on CSS/JS URLs. Run a quick Lighthouse pass for broken a11y and huge images. Submit the sitemap if it is a content site.

  • Publish directory is correct (no accidental parent folder)
  • HTTPS works and HTTP redirects
  • Article deep links return 200
  • CSS/JS load (watch base path on GitHub project pages)
  • HTML short cache; hashed assets long cache
  • robots.txt and sitemap.xml reachable

Which host I choose

Netlify or similar PaaS for speed and preview deploys. GitHub Pages for simple docs when the repo already lives on GitHub and the base path is acceptable. Nginx when I need custom headers, multiple sites on one box, or data residency constraints.

Start on a PaaS. Move to a VPS when you have a concrete reason. Premature Nginx is unpaid sysadmin work.

Custom domains and DNS gotchas

Point the apex and www with the records your host documents. Apex often needs ALIAS/ANAME or A records; CNAME-only setups fail on bare domains. Wait for DNS TTL before declaring failure.

Set canonical host redirects early—www to apex or the reverse—so analytics and sitemaps do not split. Mixed content warnings usually mean an asset still referenced via http://; fix the HTML, not only the certificate.

For BudhiWorks-style article sites, keep canonical link tags aligned with the final domain. Publishing under a netlify.app URL and later moving to a custom domain without updating canonicals confuses search engines.

SPAs versus multi-page static sites

Multi-page static HTML (like this blogging repo) needs try_files that serve real files and a clean 404. SPA mode that rewrites everything to index.html will hide missing articles as the homepage shell—bad for content sites.

If you do ship an SPA admin elsewhere, use a separate location block or host. Do not reuse the blog’s Nginx try_files for an app that needs history API fallbacks.

Precompress assets with gzip or brotli at the CDN. Nginx can serve precompressed .br files when configured. It is free performance for text-heavy blogs.

  • Content sites: real paths and real 404 pages
  • SPA fallbacks only where the app needs them
  • Align canonical URLs with the custom domain
  • Enable HTTPS redirects and HSTS once stable

Headers worth setting on day one

Besides cache policy, I set X-Content-Type-Options nosniff, a conservative Referrer-Policy, and a Content-Security-Policy that fits the site. Static blogs can use strict CSPs more easily than complex SPAs.

HSTS comes after you are sure HTTPS works on all subdomains you cover. Premature HSTS with includeSubDomains has locked teams out of internal HTTP tools on sibling hosts.

Security headers do not replace careful script hygiene. They reduce blast radius when a mistake happens.

Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; \
  style-src 'self' 'unsafe-inline'; script-src 'self' https://pagead2.googlesyndication.com; \
  frame-ancestors 'none';
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff

Rollback in under a minute

Netlify and similar hosts keep deploy history—rollback is a click. On GitHub Pages, redeploy the previous commit SHA. On Nginx, keep the last rsync tree under /var/www/site-prev and symlink swap atomically.

Practice rollback once before you need it. The first bad CSS deploy at peak reading time is a poor moment to learn your host’s UI.

For content sites, prefer fixing forward when the error is a typo. Prefer rollback when the error is “deleted the stylesheet” or “wrong publish folder emptied the site.”

Ship checklist I reuse

Before DNS cutover: dry-run deploy on a preview URL, click three article links, validate CSS loads, confirm sitemap and robots, confirm 404 page exists.

At cutover: lower TTL ahead of time if you control DNS, flip records, watch HTTPS cert issuance, purge CDN if needed, verify canonical host redirect.

After cutover: spot-check Search Console or analytics for spike in 404s, keep the previous deploy ready for rollback for 24 hours, then delete stale hosts so you do not accidentally edit the wrong place next week.

Static sites are simple until they are not. The steps above keep Netlify, GitHub Pages, and Nginx deploys predictable enough that content work stays the hard part—which is where you want the effort.

  • Preview before production DNS
  • Verify deep links and assets on the real host
  • Keep a one-step rollback ready for a day
  • Monitor 404s after cutover

Key takeaways

  • Static hosting is a publish-directory problem plus HTTPS and cache policy.
  • Netlify.toml (or equivalent) keeps headers and publish settings in git.
  • GitHub Pages project URLs need correct base paths for assets.
  • On a VPS, rsync --delete and Certbot plus clear Nginx cache rules.
  • Cache hashed assets forever; revalidate HTML so references stay fresh.
  • Verify deep links, HTTPS, and asset URLs after every first deploy to a new host.

About the author

Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.

More about Ram · Contact

← Kubernetes Secrets Management Basics Witho… Start here →