Build

A site launch checklist for a static site

On launch day the files are usually fine and the leftovers are the problem: a noindex tag inherited from staging, a canonical that names a development host, a 404 page that answers with status 200. Each can sit unnoticed for weeks. This checklist runs in two passes: a script over the built folder before you switch anything on, and a handful of curl commands against the live host afterwards.

The deployment mechanics are already covered elsewhere. For DNS, certificates and rollback see pointing a domain at a new host without downtime; for redirects and the canonical host, canonical URLs, redirects and the www question; for crawl files, sitemaps, robots.txt and Search Console. This page covers what those do not: the things you can verify mechanically, and what to read in the result.

Pass 1: audit the built folder

Run this before the first deploy and before every later one. It walks a folder of HTML files with the node-html-parser package and checks the things a template change most often breaks. Pass it the folder and the origin you intend to publish on.

// audit-dist.mjs: node audit-dist.mjs ./dist https://example.com
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { parse } from 'node-html-parser';

const [dist, origin] = [process.argv[2], process.argv[3]];
const problems = [];
const titles = new Map();
const descriptions = new Map();

function pages(dir) {
  return readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
    e.isDirectory() ? pages(join(dir, e.name)) : e.name.endsWith('.html') ? [join(dir, e.name)] : []);
}
const isFile = (p) => existsSync(p) && statSync(p).isFile();
const exists = (path) => [path, path + '.html', join(path, 'index.html')].some(isFile);

let count = 0;
for (const file of pages(dist)) {
  count++;
  const name = '/' + relative(dist, file);
  const doc = parse(readFileSync(file, 'utf8'));
  const errorPage = name.endsWith('404.html');
  const add = (msg) => problems.push(name + ': ' + msg);

  if (!doc.querySelector('html')?.getAttribute('lang')) add('no lang on <html>');
  if (!doc.querySelector('meta[name="viewport"]')) add('no viewport meta');

  const title = doc.querySelector('title')?.text.trim();
  if (!title) add('empty <title>'); else titles.set(title, [...(titles.get(title) || []), name]);
  const desc = doc.querySelector('meta[name="description"]')?.getAttribute('content')?.trim();
  const h1s = doc.querySelectorAll('h1').length;
  if (h1s !== 1) add(h1s + ' <h1> elements');

  if (!errorPage) { // error pages are not meant to be indexed, so skip the search-facing checks
    if (!desc) add('no meta description'); else descriptions.set(desc, [...(descriptions.get(desc) || []), name]);
    const canonical = doc.querySelector('link[rel="canonical"]')?.getAttribute('href');
    if (!canonical) add('no canonical');
    else if (!canonical.startsWith(origin + '/')) add('canonical points off-origin: ' + canonical);
    const robots = doc.querySelector('meta[name="robots"]')?.getAttribute('content') || '';
    if (robots.includes('noindex')) add('noindex left in: ' + robots);
  }

  for (const el of doc.querySelectorAll('[src], [href], [action]')) {
    const url = el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('action');
    if (url.startsWith('http://') && el.tagName !== 'A') add('insecure subresource ' + url);
    if (/localhost|127\.0\.0\.1|staging\./.test(url)) add('development address ' + url);
    if (el.tagName === 'A' && url.startsWith('/') && !url.startsWith('//')) {
      const path = join(dist, url.split('#')[0].split('?')[0]);
      if (!exists(path)) add('internal link has no file: ' + url);
    }
  }
  for (const img of doc.querySelectorAll('img')) if (img.getAttribute('alt') === undefined) add('<img> without alt: ' + img.getAttribute('src'));
}
for (const [label, map] of [['title', titles], ['description', descriptions]])
  for (const [text, where] of map) if (where.length > 1) problems.push('duplicate ' + label + ' on ' + where.join(', '));

console.log(count + ' pages audited');
console.log(problems.length ? problems.join('\n') : 'ok');
process.exitCode = problems.length ? 1 : 0; // a non-zero exit stops a build or deploy script

On a small fictional folder with a clean home page, an about page and a 404 page, it printed:

3 pages audited
ok

On a second folder seeded with faults it printed:

3 pages audited
/about/index.html: 2 <h1> elements
/about/index.html: no meta description
/about/index.html: noindex left in: noindex, nofollow
/about/index.html: insecure subresource http://localhost:3000/team.png
/about/index.html: development address http://localhost:3000/team.png
/about/index.html: <img> without alt: http://localhost:3000/team.png
/index.html: canonical points off-origin: https://staging.example.com/
/index.html: development address https://staging.example.com/
/index.html: internal link has no file: /contact/
/index.html: insecure subresource http://example.com/app.js
duplicate title on /about/index.html, /index.html

Each line points at a real failure mode.

  • A leftover noindex. Staging sites are often marked noindex so they stay out of search, and the tag travels with the template. Google's documentation says that when Googlebot crawls a page and extracts the tag, Google drops the page entirely from its results, whatever links to it. The script reports it on every page except the 404 page, where it does no harm.
  • A noindex and a robots.txt block together. The same documentation warns that for noindex to work, the page must not be blocked by robots.txt. If it is blocked, the crawler never sees the rule. At launch, remove the tag and also any blanket Disallow rule left over from staging, because a Disallow stops the crawler from reading the page at all.
  • A canonical on the wrong origin. A template that builds absolute URLs from an environment variable can ship the staging address. The script compares the canonical with the origin you pass in.
  • Insecure subresources. MDN says browsers auto-upgrade image, video and audio requests from HTTP to HTTPS and block insecure requests for all other resource types. Images and media are rewritten when the host supports HTTPS, and everything else, scripts and stylesheets included, is blocked, so an address that worked on a developer machine can be dead for visitors.
  • Missing alt, duplicate titles, two h1 elements, dead internal links. These are quality checks that cost nothing to automate. The script limits the link check to internal links, because outbound links belong to a live check.

The script ends by setting a non-zero exit code when it finds anything, so you can chain it into the build command (build, then audit, then deploy) and the chain stops on the first fault. Extend it with your own recurring mistakes. A check added the day a mistake happens is a check that mistake cannot repeat, and over a few months the script becomes a written record of what has gone wrong on your sites. Keep each check to one line of output with the page and the fault, so a failing deploy tells you where to look without a second command.

Pass 2: audit the live host

After the switch, ask the host directly. The commands below use curl: -s silences the progress meter, -o /dev/null discards the body, -w prints chosen facts about the response such as the status code, -I fetches headers only, and -H sends a request header. Each check is one line.

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://example.com/
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/a-page-that-does-not-exist
curl -sI -H "Accept-Encoding: gzip, br" https://example.com/

Run against example.com, a reserved domain used for documentation, on 2026-10-17, these requests and a few of the same kind (the www host, the favicon path, the response headers) returned the results below. The point of the table is how to read each result. Your host will differ, and some of these results would be failures on a real site.

CheckResult for example.comHow to read it
Plain HTTP request200, no redirectThe page is served over HTTP. On your site, expect a permanent redirect to HTTPS
Both www and bare hostBoth answered 200Two hostnames serve the same page. Choose one and redirect, or rely on the canonical tag
Unknown path404 with a text/html bodyA true 404 status, the right behaviour
Content typetext/html; charset=utf-8Declares the character encoding
Compressioncontent-encoding: br when asked for gzip or brThe host compresses text responses
strict-transport-securityHeader absentNo HSTS
cache-controlHeader absent, last-modified presentHeuristic caching applies; see below
/favicon.ico404No icon at the default path

Redirects, HTTPS and HSTS

The first row is the one to take seriously. MDN's HSTS page says that a host accepting insecure HTTP requests should answer with a permanent redirect carrying an https URL in the Location header, and that the Strict-Transport-Security header must be sent over HTTPS only, since browsers ignore it over HTTP. Once a browser has seen the header it upgrades future requests to that host itself. Start with a plain max-age. MDN notes that includeSubDomains applies the policy to every subdomain and blocks access to those that can only be served over HTTP, and that the preload list needs at least a one-year max-age plus includeSubDomains and sits outside the HSTS specification. Decide on those after you have listed every subdomain you use.

Status codes

A designed 404 page is good practice, so long as its status is 404. Google's documentation describes how a 2xx response whose content is an error message or an empty page is reported in Search Console as a soft 404, and that a URL returning a 4xx status is not indexed, and one already indexed is removed. A static host usually returns the right status for a file named 404.html, but check with the unknown-path command above, because a single-page fallback rule can quietly change it to 200.

Caching headers

The absent Cache-Control in the table matters. MDN explains that without it, responses are not left uncached. Caches can apply heuristic caching: when a Last-Modified header exists, the specification encourages a freshness lifetime no larger than some fraction of the time since that date, with 10 percent given as a typical setting. The response above had a Date of 17 October 2026 19:40:45 GMT and a last-modified of 13 October 2026 22:05:48 GMT. That is 336,897 seconds, or 3.9 days. Ten percent of it is 9.36 hours during which a cache that follows the suggestion could reuse the page without asking again. Whether a given cache does is up to the cache, so the safe course is to say what you want.

A reasonable starting policy for a static site follows MDN's guidance:

ResourceHeaderReason
Files whose names change with their content (fingerprinted CSS, scripts, images)Cache-Control: max-age=31536000, immutableA new build produces new names, so the old ones can be kept for a year, and immutable stops revalidation on reload
HTML pages and anything with a fixed addressCache-Control: no-cacheno-cache lets caches store the page but requires validation before reuse, so an edit appears on the next visit

MDN warns that no-cache does not guarantee revalidation for history navigations, and it recommends against granting no-store liberally because it forfeits benefits such as the back/forward cache. Many static hosts set these headers by default. Read what yours sends before changing anything.

What the scripts cannot see

Some launch failures have no mechanical signature, so reserve a short manual pass in a private browser window, which carries no cached state or sign-in.

  • Open the home page on a phone-sized viewport and read the first screen. Check that the main action is visible and that nothing is cut off.
  • Tab through the page with the keyboard only. Focus should be visible at every stop and nothing should trap it.
  • Submit every form once with a real message, and confirm it arrives where the owner will read it.
  • Follow each link in the header and footer, including the legal and contact pages, to make sure none is a placeholder.
  • Check that analytics or error reporting, if you use them, record your own test visit.

Then set an uptime check on the home page, so a later outage reaches you before a visitor. Put a date in the calendar to run both passes again after the first month of content changes. Launch audits find the faults that were present on launch day, and the faults that arrive later come from edits.

Search-facing finishing

  • Favicon. Google's favicon guidelines ask for a square image at least 8 by 8 pixels, recommend one larger than 48 by 48 pixels, require a stable URL, and say the icon is not guaranteed to appear. Declare it with a link tag on the home page and confirm the file returns 200.
  • Search Console. Verify the property, submit the sitemap, and inspect the home page with the URL Inspection tool to see what Google actually received.
  • Social previews. Share the home page URL into a private chat to see the preview before anyone else does.

Launch order

  1. Run pass 1 on the final build and fix everything it reports.
  2. Deploy to the new host without changing DNS, and test it with the host's preview address (preview deployments and rollbacks explains the options).
  3. Switch DNS using the plan in the migration article.
  4. Run pass 2, starting with the redirect and the unknown-path check.
  5. Verify Search Console, submit the sitemap, and inspect the home page.
  6. Re-run both passes a day later and after the first content change.

Checklist

  • Audit the built folder for noindex, canonicals, titles, h1 count, insecure subresources and dead links.
  • Confirm HTTP redirects to HTTPS and that unknown paths return a true 404.
  • Set Cache-Control deliberately: a long immutable lifetime for fingerprinted files, no-cache for HTML.
  • Add HSTS with a plain max-age first; hold preload until every subdomain is ready.
  • Verify the property in Search Console and inspect the home page.

Questions and answers

Why audit the built folder instead of the source?

The built folder is what visitors and crawlers receive. A template can look right and still output a duplicate title, a leftover noindex tag or an address that points at a development host, and only the output shows it.

Is Cache-Control required?

No header is required, but leaving it out does not turn caching off. MDN explains that caches may apply heuristic caching, reusing a response for a fraction of the time since it was last modified. State the behaviour you want explicitly.

Should I add HSTS with preload at launch?

Add a plain max-age first and check that every subdomain you use works over HTTPS. MDN notes that includeSubDomains blocks access to subdomains that can only be served over HTTP, and that preloading requires at least a one-year max-age and includeSubDomains.

Does a custom 404 page need to return 404?

Yes. Google's documentation says a 2xx response whose content looks like an error page or an empty page is reported in Search Console as a soft 404. The page can be designed however you like as long as the status code is 404.

Sources

  1. HTTP caching (MDN) developer.mozilla.org
  2. Strict-Transport-Security header (MDN) developer.mozilla.org
  3. Mixed content (MDN) developer.mozilla.org
  4. How HTTP status codes affect Google's crawlers (Google Search Central) developers.google.com
  5. Block Search indexing with noindex (Google Search Central) developers.google.com