Your project works, it is tested, it is measured and a machine watches it on every push. And yet it still lives on your laptop, where it is no use to anybody. Deploying is what turns a repository into a product, and it is also the moment when the problems no development environment teaches you show up: the routes that worked on localhost and give a 404 on the server, the cache that serves the old version for a week, the service worker stuck with code from three deployments ago, the API key you thought was protected and that anybody can read in two clicks, and the security headers nobody configures because nobody has explained them. In this lesson you will learn to prepare the production build while understanding what each part does and why no secret key can live in the client; to choose where to host with an honest comparison table; to configure the server properly — SPA routes, caching consistent with hashing and with the service worker, compression and security headers explained one by one; to decide what to do about the backend without writing one, with the warning that client-side validation never replaces server-side validation; to set up continuous deployment with per-branch previews and a rollback strategy that works at three in the morning; to monitor in production without becoming a privacy problem; and to update an already-installed PWA without leaving anybody on an old version. You will finish with the application deployed over HTTPS, its continuous deployment pipeline and a signed launch checklist.

Contents

  1. What changes when the code leaves your machine
  2. The production build
  3. Environment variables and why there are no secrets in the client
  4. Where to host: an honest comparison table
  5. Domain, HTTPS and certificates
  6. SPA routes and the index.html fallback
  7. Caching: the part most often done wrong
  8. Compression
  9. Security headers, one by one
  10. The backend: what options exist without writing a server
  11. Client-side validation never replaces server-side validation
  12. Continuous deployment with GitHub Actions
  13. Per-branch preview environments
  14. Rollback strategy
  15. Error monitoring in production
  16. Field metrics with web-vitals
  17. Updating an already-installed PWA
  18. The launch checklist
  19. Common Mistakes and Tips
  20. Exercises
  21. Conclusion

  1. What changes when the code leaves your machine

In development, Vite does an enormous number of things for you that in production simply do not exist. It is worth seeing the full list first, because every row is a source of surprises:

Aspect In development (npm run dev) In production
Modules Served unbundled, one per file Bundled and minified
Routes The server returns index.html for everything Returns 404 unless you configure it
Cache Disabled Aggressive, and hard to undo
Errors Visible console, with source maps Nobody sees them unless you collect them
Network Local, instant Real latency, packet loss, 3G
Environment variables From the .env file Whatever the build process injects
HTTPS Optional Mandatory (and many APIs require it)
Service worker Usually disabled Active, and caching persistently
Users You, in a recent browser Anybody, on anything

The practical consequence is a golden rule that saves a great deal of time:

Never deploy anything you have not tested with npm run build and npm run preview on your machine.

preview serves the real build from dist/, and that is where half the problems show up: badly resolved routes, images that were not copied, dynamic imports that worked by accident in development, and environment variables that were not injected.

And a second rule, from the tip in 11-01 that I hope you followed: the first deployment is done with the application empty, at milestone H1. If you did that, this section is a recap. If not, do it now before going on: discovering configuration problems with a blank page costs an hour; discovering them with the whole product on the line costs a weekend.

  1. The production build

npm run build

And what it produces, annotated:

dist/
├── index.html                      2.1 kB   ← hashed references
├── manifest.json                   0.6 kB
├── sw.js                           4.3 kB   ← NO hash, on purpose
├── assets/
│   ├── index-C4f8a2b1.js          38.2 kB  ← the main bundle
│   ├── report-a91b3c7d.js          9.4 kB  ← chunk loaded on demand
│   ├── calendar-7f2e9d10.js        6.5 kB
│   └── index-B2d91e0f.css         11.8 kB
└── icons/
    ├── icon-192.png
    └── icon-512.png

Four things happen here and each one is worth understanding.

1 · Bundling. The hundreds of ES modules in src/ are combined into a few files. In development every import was a request; in production there are three or four downloads. It is what makes the "≤ 4 requests" budget from 11-01 achievable.

2 · Code splitting (09-05). The screens not shown at startup come out in separate chunks, loaded when they are needed:

// The report is only downloaded if somebody opens it
const { createReportView } = await import('./view/report-view.js');

3 · Minification. Whitespace, comments and long names are removed, and optimizations like tree shaking are applied: if you import one function from a module and do not use the other five, the other five are not included. This only works with static ES modules, which is one of the reasons 05-04 insisted on them.

4 · Filename hashing. index-C4f8a2b1.js contains a digest of the content. If the content changes, the name changes. This is the piece that makes the caching strategy in section 7 possible, and it is worth seeing clearly:

Situation Name Consequence
No hash index.js You have to choose between a long cache (users on old versions) or a short one (a download on every visit)
With a hash index-C4f8a2b1.js A one-year cache and immediate updates: the new file has a different name

It is an elegant solution to a problem that looked unsolvable, and it explains why index.html does not carry a hash: somebody has to be the stable entry point pointing at the hashed files.

Annotated Vite configuration:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  base: '/orbita/',          // ← if you serve from a subdirectory (GitHub Pages)
  build: {
    outDir: 'dist',
    sourcemap: true,         // ← source maps: see below
    target: 'es2020',
    rollupOptions: {
      output: {
        manualChunks: {
          // Separate what changes little from what changes a lot
          domain: ['./src/domain/task.js', './src/domain/board.js', './src/domain/tree.js']
        }
      }
    },
    chunkSizeWarningLimit: 60   // warns if a chunk goes over your budget
  }
});

base is the number one cause of broken deployments. If you serve at https://user.github.io/orbita/, the absolute paths /assets/... would point at the root of the domain, where there is nothing. With base: '/orbita/', Vite generates /orbita/assets/.... On Netlify, Vercel or your own domain, where the application is at the root, base must be '/'.

About sourcemap: true, which has a nuance worth deciding consciously: source maps let you debug in production seeing your original code instead of the minified one, and they are essential for an error-tracking service to give readable stack traces. The trade-off is that they expose your source code. For a portfolio project with a public repository, there is no reason to hide it. In a commercial product, the usual practice is to generate them and upload them to the error service without publishing them on the server.

  1. Environment variables and why there are no secrets in the client

Vite injects into the bundle any variables starting with VITE_:

# .env.production
VITE_API_URL=https://api.orbita.example/v1
VITE_ENVIRONMENT=production
VITE_VERSION=1.0.0
const base = import.meta.env.VITE_API_URL;

And here comes the most important point in the whole lesson, which has to be understood without ambiguity:

import.meta.env.VITE_WHATEVER is literally substituted with its value at build time. That value ends up written into a .js file that gets downloaded into anybody's browser. It is not a variable: it is a public constant.

Check it yourself, and do it now:

npm run build
grep -r "VITE_" dist/          # you can see the replaced names
grep -r "sk_live\|api_key\|secret" dist/    # ← this must return ZERO results

What can go in the client and what cannot:

Can go in Can never go in
Your API's public URL Secret API keys
The environment name Passwords or database connection strings
The application version Token-signing secrets
Public service identifiers (Google Analytics, a public Sentry DSN) Email credentials, payment gateways, cloud services
Non-sensitive feature flags Anything granting access to data that is not that user's

Why people get this wrong, and with what consequences. The faulty reasoning is: "it is in an environment variable, and environment variables are secret". They are on the server. In a client build, the environment variable only describes where the value was written, not where it ends up. And the value ends up in a public file, minified but perfectly readable with Ctrl+F.

The consequences are real and expensive: cloud service keys leaked into public repositories are detected automatically by bots within minutes, and the result is usually a bill for somebody else's usage.

What to do if your project needs to talk to a service requiring a secret key. There is only one correct answer: a server-side intermediary.

flowchart LR
    A["Browser<br/><i>no secrets</i>"] -->|"public request"| B["Your server function<br/><i>holds the key</i>"]
    B -->|"secret key"| C["External service"]
    C --> B --> A

    style A fill:#dcfce7,stroke:#16a34a
    style B fill:#dbeafe,stroke:#2563eb

Netlify Functions, Vercel Functions and Cloudflare Workers let you do this in twenty lines and without setting up a full server. That is section 10.

And the repository rule: .env in .gitignore, always, with a versioned .env.example documenting which variables are needed and with sample values. If you ever commit a secret by mistake, rotating it is mandatory: deleting it from the history is not enough, because it is already in any clone made in the meantime.

  1. Where to host: an honest comparison table

An application like yours is a static site: HTML, CSS, JavaScript and a few images. There is no server process running your code. That opens up plenty of options and all of them are good; the differences are in the details.

Platform Cost Deployment Previews Server functions Custom headers Custom domain When to choose it
GitHub Pages Free GitHub action Very limited ✅ with HTTPS Pure portfolio; the simplest thing
Netlify Generous free tier Git or CLI ✅ per PR ✅ Functions _headers, _redirects The recommended one for this project
Vercel Generous free tier Git or CLI ✅ per PR ✅ Functions vercel.json Very similar; excellent if you later use Next.js
Cloudflare Pages Very generous free tier Git or CLI ✅ per branch ✅ Workers _headers Excellent global network; generous limits
Your own server + Nginx The server's cost Yours (rsync, CI) Manual Whatever you build Total control ✅ (Let's Encrypt) When you need control or already have a server

What the table does not say and is worth knowing:

GitHub Pages is the simplest option and has one limitation that directly affects this project: it does not allow configuring HTTP headers. That means you cannot set a real Content-Security-Policy (only the <meta> version, which is more limited), nor control Cache-Control, nor configure the SPA fallback except with the 404.html trick. For a portfolio it is perfectly valid; for practicing section 9 of this lesson, it is not.

Netlify, Vercel and Cloudflare Pages do essentially the same thing from a static site's point of view: you connect the repository, every push to main deploys, every PR generates a preview URL, and you have a configuration file for headers and redirects. Choosing between them for this project is a matter of preference; all three are excellent.

Your own server with Nginx is the only one that forces you to understand what is going on, and precisely for that reason it is instructive. It is also the only one where you are responsible for security updates, backups and the certificate.

A warning about free tiers, because honesty is called for: they are generous and sufficient for a personal project, but they have limits (build minutes, bandwidth, function invocations) and they change over time. Read the current limits before committing, and bear in mind that a project that grows may end up needing a paid plan.

Recommendation for this module: deploy on Netlify or Cloudflare Pages. You get per-PR previews, configurable headers and server functions if you need them, at no cost and with nothing to administer. And if you want the full exercise, set up an equivalent Nginx configuration as well, even if only locally: understanding the configuration file teaches more than any control panel.

  1. Domain, HTTPS and certificates

HTTPS is not optional, and not only for security:

Without HTTPS these do not work Reason
Service workers The specification requires it
Notifications, geolocation, camera, clipboard Secure contexts are mandatory
crypto.subtle Same
Installation as a PWA Manifest requirement
The user's trust The browser displays "Not secure"

On top of that, the data travels in the clear: on public wifi, anybody can read and modify what is sent.

How you get it. On the platforms from the previous section, automatically and for free: they issue and renew the certificate for you. On your own server, with Let's Encrypt and certbot:

sudo certbot --nginx -d orbita.example -d www.orbita.example
# Automatic renewal every 60 days via a system timer
sudo certbot renew --dry-run

The domain. A domain of your own costs ten to fifteen euros a year and completely changes how the project is perceived. Configuration is a DNS record:

Type Name Value What for
CNAME www your-site.netlify.app The subdomain points at the platform
A or ALIAS @ The IP or alias the platform gives you The root domain

And one decision you have to make and not leave half-done: choose one canonical form — with www or without — and redirect the other with a 301. Having both live duplicates the content for search engines, splits the cache and confuses users. The same applies to httphttps, which must be a permanent redirect.

HSTS (Strict-Transport-Security) tells the browser never to try connecting over HTTP to your domain:

Strict-Transport-Security: max-age=31536000; includeSubDomains

It is one of the most effective headers there is. With one important warning: it is hard to undo. Once a browser has seen it, it refuses to use HTTP on that domain for the max-age even if you remove the header. Start with a small value (max-age=300), check that everything works, and then raise it to a year.

  1. SPA routes and the index.html fallback

This is the problem that surprises everybody on their first deployment.

Your router (11-01) uses the History API: when you navigate to the report view, the URL becomes https://orbita.example/report. It works perfectly… until somebody reloads the page or shares that link.

sequenceDiagram
    participant U as User
    participant S as Server
    U->>S: GET /report
    S->>S: Does the file /report exist?
    S-->>U: 404 Not Found ❌
    Note over U,S: Your application never even loads

The cause is simple: the server knows nothing about your router. It looks for a file called report and there is none.

The solution is called the index.html fallback: any route that does not correspond to a real file returns index.html, and then your JavaScript boots, reads the URL and renders the right screen.

With one important caveat: the fallback must return status 200, not 404, or search engines will index your routes as errors.

On Netlify (public/_redirects):

/*    /index.html   200

On Vercel (vercel.json):

{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

On Cloudflare Pages (public/_redirects): identical to Netlify.

On Nginx:

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

try_files tries in order: the exact file, the directory, and if neither exists, index.html. It is exactly the logic you need.

On GitHub Pages there is no server configuration, so a trick is used: create a 404.html identical to index.html. GitHub serves it for non-existent routes and the application boots. It works, but it returns status 404, which is bad for SEO. It is one of the reasons to prefer another platform if indexing matters to you.

And the detail that breaks the fallback: if you apply the rule to everything, /api/tasks will also return index.html, and your code will receive HTML where it expected JSON — with a baffling parse error. Exclude the API routes before the general rule:

location /api/ { proxy_pass http://127.0.0.1:3000; }   # before location /
location /     { try_files $uri $uri/ /index.html; }

  1. Caching: the part most often done wrong

Caching is where people go wrong most, and it produces two opposite and equally bad failures:

Failure Cause Symptom
Everything is cached too much A long cache on index.html Users stay on the old version for days
Nothing is cached No Cache-Control Everything is downloaded on every visit: slow and expensive

The correct solution takes advantage of the hashing from section 2, and it boils down to a two-line rule:

Files with a hash in the name: one-year cache, immutable. The index.html, the service worker and the manifest: never cached.

The logic is impeccable: if the content changes, the name changes, so a hashed file never needs revalidating. And index.html, which is what points at the new names, must always be requested.

The complete table:

Resource Cache-Control Why
assets/*-[hash].js and .css public, max-age=31536000, immutable The name changes if the content changes
Hashed images public, max-age=31536000, immutable Same
Fonts public, max-age=31536000, immutable They rarely change; if they do, they get a hash
index.html no-cache It must always revalidate to discover the new names
sw.js no-cache If it is cached, users are stuck with the old service worker
manifest.json no-cache or max-age=3600 It changes little but must be updatable
API responses no-store if private Do not cache user data in intermediaries

no-cache does not mean "do not store". It means "store, but revalidate with the server before using". With ETag, revalidation usually returns a 304 with no body: minimal cost and always up to date. The one that stores nothing is no-store.

On Netlify or Cloudflare Pages (public/_headers):

/assets/*
  Cache-Control: public, max-age=31536000, immutable

/index.html
  Cache-Control: no-cache

/sw.js
  Cache-Control: no-cache

/manifest.json
  Cache-Control: no-cache

On Nginx:

# Hashed files: maximum caching
location ~* ^/assets/.*\.[0-9a-zA-Z_-]{8}\.(js|css|woff2|png|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# The entry point and the service worker: never cached
location = /index.html { add_header Cache-Control "no-cache"; }
location = /sw.js      { add_header Cache-Control "no-cache"; }

The special case of the service worker deserves emphasis because it produces the most baffling failure of all. If sw.js is served with a long cache, the browser does not download the new one, so the old service worker keeps serving the old version of your application indefinitely. The user reloads, clears their history, and keeps seeing the same thing. Modern browsers limit sw.js caching to 24 hours by default, but do not rely on that: set an explicit no-cache.

How to check it is configured correctly:

curl -sI https://orbita.example/assets/index-C4f8a2b1.js | grep -i cache-control
# → cache-control: public, max-age=31536000, immutable

curl -sI https://orbita.example/index.html | grep -i cache-control
# → cache-control: no-cache

  1. Compression

Compressing is the best effort-to-result optimization there is: one line of configuration and between 60 % and 80 % fewer bytes on text.

Algorithm Typical reduction on JS Compatibility CPU cost
Uncompressed All 0
gzip ~70 % Universal Low
Brotli ~75–80 % All current browsers Higher when compressing

Managed platforms do it by themselves and you have nothing to configure. On Nginx:

gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;      # compressing 200-byte things is not worth it

# Brotli, if the module is available
brotli on;
brotli_types text/css application/javascript application/json image/svg+xml;

Two important nuances:

  • Do not compress what is already compressed. PNG, JPEG, WebP, MP4 and WOFF2 already are. Recompressing them burns CPU and sometimes increases the size.
  • Your budget from 11-01 is in compressed bytes. The 60 kB in the budget is measured in the Network panel's "Transferred" column, not "Size". Confusing the two makes you think you are over budget when you are under, or the other way round.

  1. Security headers, one by one

These headers are free, configured once, and they close off entire classes of attacks. They are explained one at a time because copying them without understanding them produces broken sites nobody knows how to fix.

9.1 Content-Security-Policy

It is the most powerful and the most delicate. It declares where your page may load resources from; everything else is blocked by the browser.

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.orbita.example; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'

Directive by directive:

Directive What it controls Value and why
default-src 'self' Anything not specified Only your own origin
script-src 'self' Where scripts are loaded from The key one: blocks injected and inline scripts
style-src 'self' Stylesheets Same for CSS
img-src 'self' data: Images data: allows inline SVG and embedded icons
connect-src fetch, XHR, WebSocket Only your API: if code is injected, it cannot exfiltrate data to another server
object-src 'none' <object>, <embed> You do not need them and they are classic vectors
base-uri 'self' The <base> tag Prevents rewriting all your relative paths
form-action 'self' Form targets Prevents a form from sending data elsewhere
frame-ancestors 'none' Who can put you in an iframe Prevents clickjacking; supersedes X-Frame-Options

Why CSP is so effective: it is the second line of defense against XSS. In 11-03 you learned the first — textContent instead of innerHTML — and CSP is the reserve parachute: even if an attacker manages to inject <script>alert(1)</script> into your page, script-src 'self' prevents it from running, because it does not come from your origin.

The two practical pitfalls:

  1. Inline styles. If your code does element.style.color = 'red', CSP with style-src 'self' blocks it. The correct solution is to use CSS classes instead of inline styles — which is better practice anyway. The lazy solution is 'unsafe-inline', which disables much of the protection. Avoid it.
  2. Inline scripts. If you have a <script> with code inside the HTML, it is blocked. Vite does not generate any by default, so it is usually not a problem.

How to roll it out without breaking anything: start in report-only mode, which blocks nothing and tells you what it would block:

Content-Security-Policy-Report-Only: default-src 'self'; ...

Navigate through your whole application, look at the console warnings, adjust the policy, and only then remove the -Report-Only.

9.2 The rest

X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
Header What it does Why you want it
X-Content-Type-Options: nosniff Stops the browser guessing a file's type from its content Without it, a user-uploaded file that "looks like" JavaScript could be executed as such
Referrer-Policy How much of the originating URL is sent when navigating away strict-origin-when-cross-origin sends the full URL within your site and only the domain outward: it prevents leaking identifiers from your URLs to third parties
Permissions-Policy Disables browser features you do not use If you use neither camera nor microphone, declaring it closes the door to an injected script asking for them
Strict-Transport-Security Forces HTTPS Section 5. Careful with max-age at first
X-Frame-Options: DENY Prevents you being put in an iframe Redundant with frame-ancestors but covers older browsers

On Netlify or Cloudflare Pages (public/_headers):

/*
  Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.orbita.example; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
  Strict-Transport-Security: max-age=31536000; includeSubDomains

How to verify it. There are public services that analyze your headers and give you a score with explanations. It is also enough to run:

curl -sI https://orbita.example/ | grep -iE "content-security|x-content|referrer|permissions|strict-transport"

Getting a good security-headers score is a small detail that carries a lot of weight in a technical review: it shows you have thought about deployment, not just about the code.

  1. The backend: what options exist without writing a server

Your project can work perfectly without a server: the data in localStorage, everything in the browser. It is a legitimate decision if it is documented with its limitations (single device, no real privacy, no sharing).

If you need more, there are three paths:

Option What it is Effort Control When
No backend Everything in the browser None Total over the client MVP, portfolio, personal use
Backend as a service (BaaS) A service gives you a database, authentication and an API Low Medium; you depend on the provider When you need multi-user without writing a server
Serverless functions Small pieces of server code for specific cases Low-medium High over what you write Hiding a key, sending an email, validating something
Your own backend Node.js + a database, written by you High Total When the server logic is the product

Backend as a service. Services like Supabase, Firebase or PocketBase give you a database, an automatic API, authentication and sometimes real time, through configuration rather than code. For a portfolio project that needs users and shared data, it is the option with the best effort-to-result ratio.

What you should know before choosing one:

  • You depend on the provider. Migrating later has a cost. Mitigate it by keeping your repository boundary (11-02): if the BaaS lives behind SupabaseRepository, changing it means rewriting one file.
  • The security rules are your responsibility. These services expose the database directly to the client, protected by rules you configure. One badly written rule exposes every user's data. It is the most frequent and most serious failure on these platforms.
  • Free tiers have limits and the service can change its pricing or disappear.

Serverless functions. When all you need is to hide a key or perform an occasional server-side operation:

// netlify/functions/exchange-rate.js — the key NEVER reaches the browser
export async function handler(event) {
  const key = process.env.SERVICE_KEY;              // a server variable, not VITE_
  const response = await fetch(`https://service.example/api?key=${key}`);
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
    body: JSON.stringify(await response.json())
  };
}

Notice that the variable does not carry the VITE_ prefix: that is exactly what keeps it out of the client bundle.

Your own backend. It is the natural path if you want to grow, and it is the subject of lesson 11-07: Node.js with Express or Fastify, a database, token authentication, and all the server logic your application needs. It requires learning new things and is worth it, but it is not a requirement of this project.

  1. Client-side validation never replaces server-side validation

This section is short, it has no nuances, and it is one of the most important.

You have fifteen business rules, R1 to R15, implemented and tested in domain/. They work perfectly… in the browser. And the browser is an environment the user controls entirely:

  • They can open DevTools and call your functions with whatever they like.
  • They can modify your code before it runs.
  • They can skip your entire application and call the API with curl.
# No client-side validation is involved here
curl -X POST https://api.orbita.example/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"","estimatedHours":9999,"status":"done","assigneeId":"another-user"}'

If the server accepts that, your fifteen rules are worth nothing.

Client-side validation is a convenience for the user. Server-side validation is what protects the data. They are not alternatives: they are two layers with different purposes, and both are mandatory as soon as there is a server.

Layer Purpose What happens if it is missing
Client Immediate feedback, avoiding pointless round trips, a good experience The application feels slow and clumsy, but the data is still protected
Server Data integrity and security Anybody can write whatever they like: corrupt data, other people's accounts modified

The good news is that your architecture is already prepared for this. domain/ is plain JavaScript with no browser dependencies — that was the first boundary in 11-01. If you write the backend in Node.js, you can import exactly the same files and run the same rules on the server:

// server/routes/tasks.js — the SAME domain, nothing duplicated
import { Task } from '../../src/domain/task.js';
import { ValidationError } from '../../src/domain/errors.js';

app.post('/v1/tasks', async (request, response) => {
  try {
    const task = new Task(request.body);             // R1-R15 enforced on the server
    response.status(201).json(await repository.save(task));
  } catch (error) {
    if (error instanceof ValidationError) {
      return response.status(400).json({ code: 'VALIDATION', field: error.field, message: error.message });
    }
    throw error;
  }
});

One single implementation of the rules, executed on both sides. That is what made it worth keeping the domain free of dependencies throughout the whole project, and it is an excellent argument to tell in an interview (11-06).

And what the server must validate on top of your rules, because these are things the client cannot do:

Check Why only the server can do it
Authentication: who are you? The client can claim to be whoever it likes
Authorization: are you allowed to touch this? R15 (roles) is trivial to bypass in the client
Rate limiting Protects against abuse and against accidental loops
Maximum body size Stops somebody sending 500 MB
Real uniqueness Only the database can guarantee it without races

  1. Continuous deployment with GitHub Actions

Continuous deployment means that what is merged into main reaches production automatically, with no manual steps. With the CI from 11-04 protecting the branch, that is safe: nothing reaches main without lint, tests, coverage, budget and journeys all green.

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:        # lets you launch it by hand from the interface

concurrency:
  group: production-deploy
  cancel-in-progress: false   # do NOT cancel a deployment midway

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://orbita.example
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }

      - run: npm ci

      - name: Verify before deploying
        run: npm run verify         # belt and braces: never deploy while red

      - name: Build for production
        run: npm run build
        env:
          VITE_API_URL: ${{ vars.API_URL }}
          VITE_VERSION: ${{ github.sha }}

      - name: Check there are no secrets in the bundle
        run: |
          if grep -rEq "(sk_live|api[_-]?key|BEGIN [A-Z ]*PRIVATE KEY)" dist/; then
            echo "::error::Possible secret in dist/"
            exit 1
          fi

      - name: Deploy to Netlify
        uses: nwtgck/actions-netlify@v3
        with:
          publish-dir: './dist'
          production-deploy: true
          deploy-message: ${{ github.event.head_commit.message }}
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

      - name: Smoke check
        run: |
          sleep 15
          curl -sfI https://orbita.example/ | head -1
          curl -sf https://orbita.example/ | grep -q "Orbita" || exit 1

The file's decisions:

Element Why
cancel-in-progress: false Canceling a deployment halfway can leave the site inconsistent. Here you do wait
workflow_dispatch Being able to redeploy by hand without making an empty commit
environment with url GitHub shows the link and keeps the deployment history
npm run verify again CI already passed, but a deployment should never trust another pipeline
VITE_VERSION: github.sha Every deployment knows which commit it came from. Worth gold when debugging in production
The secrets grep The last automatic safety net before publishing
The smoke check A deployment finishing does not mean the site works

The smoke check is an idea worth internalizing: a minimal, quick verification that what was deployed responds and contains what it should. It costs five lines and catches the "an empty folder was deployed" case, which happens more often than you would think.

Repository secrets (Settings → Secrets and variables): NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID go in as secrets (encrypted, not visible even in the logs); API_URL goes in as a variable (it is not secret and so it can be read). Never write a token directly into the YAML: the file is in the repository.

  1. Per-branch preview environments

A preview environment is a temporary deployment of a branch, with its own URL. Netlify, Vercel and Cloudflare Pages do it automatically when a Pull Request is opened.

Why it is worth it, even when you work alone:

Benefit Detail
You see the change in real conditions With the production build, HTTPS, caching and the service worker
You can test it on your phone Just open the URL; no local network configuration
You can ask for feedback A link, not "clone the repo and run this"
You compare before and after Production and preview open in two tabs
Lighthouse against the real thing The CI from 11-04 can audit the preview instead of a local server

Three important precautions:

  1. noindex on previews. If Google indexes deploy-preview-42--orbita.netlify.app, you will have duplicate content competing with your site. Platforms usually set it, but check.
  2. Never point a preview at production data. A destructive test on a preview that writes to the real database is an avoidable disaster. Use a separate data environment.
  3. Previews are public. Anyone with the URL gets in. Do not test there with anybody's real data.

  1. Rollback strategy

Any deployment can go wrong. The question is not whether it will happen, but how long you will take to fix it when it does — probably at an inconvenient hour and in a hurry.

The options, from best to worst:

Strategy Time Risk Availability
Roll back to the previous deployment (the platform's button) < 1 min Very low Netlify, Vercel, Cloudflare
git revert + automatic deployment 3–5 min Low Always
Fix forward 10–60 min High under time pressure Always
Restore from a backup Variable Medium Your own server

The first is the good one, and you have to test it before you need it. Managed platforms keep every previous deployment and let you go back to any of them with one click, because each deployment is an immutable set of static files.

The written procedure, which should be in your README or in docs/operations.md:

## If a deployment breaks production

1. **Roll back first, investigate afterwards.** The immediate goal is for
   users to have something that works, not to understand what happened.
   → Netlify panel → Deploys → the previous one → "Publish deploy"
2. Check the site works (a manual smoke check).
3. `git revert <sha>` on `main` so the code reflects reality.
   Do NOT use `git reset --force` on a shared branch.
4. Open an issue with: what broke, how it was detected, what was rolled back.
5. Reproduce the failure **locally or in a preview**, with a failing test
   (the method from 11-04).
6. Fix it, with the test green, and deploy again.
7. Write a short post mortem: what happened, why CI did not catch it,
   what test or check is being added so it does not come back.

Point 1 is counterintuitive and it is the right one. The temptation is to investigate while the site is broken. But every minute of investigation is a minute of downtime, and hurry leads to worse decisions. Rolling back is reversible; fixing in a hurry is not.

Point 7 is what turns an incident into learning. And the key post-mortem question is not "who made the mistake" but "why did CI not catch it", because the answer is always a new check that prevents a whole family of future failures.

A caution about the database: if your deployment included a data migration (like the ones in 11-03), rolling back the code does not roll back the data. That is why migrations should be backward-compatible whenever possible: add fields, do not rename or delete them in the same deployment that stops using them. The safe sequence takes two deployments: first add and write to both places, then stop using the old one.

  1. Error monitoring in production

In development, an error shows up in your console. In production, it shows up in a user's console that they are not going to look at and are not going to tell you about: they will simply stop using your application.

In 11-02 you set up a local log with the two global safety nets (error and unhandledrejection). Now it has to be sent somewhere.

The options:

Option Effort What you get
Just the local log + a diagnostics panel None Nothing until somebody shows it to you
An error-tracking service Low Grouped errors, with stack trace, context and frequency
Your own endpoint receiving errors Medium Total control, and total responsibility too

Error-tracking services. Sentry, Bugsnag, Rollbar and similar have free tiers that are sufficient for a personal project, integrate in three lines and group identical errors — which matters a lot: a bug happening 400 times is one entry, not 400 emails.

And here comes the part you have to get right:

An error-tracking service sends your users' data to a third party. That is a privacy decision, not just a technical one.

What has to be configured before enabling it:

Setting Why
Filter personal data before sending Names, emails, content written by the user
Do not send request bodies They can contain anything
Mask the DOM if you use session replay A recording literally captures everything visible
Reduce the sampling rate You do not need 100 % of the events, and less data is better
Mention it in the privacy notice It is a processor; it has to be declared
Check where the data is stored Transfers outside the EU have their own requirements
// src/util/remote-logger.js
export function sendError(entry) {
  if (import.meta.env.DEV) return;

  const safe = {
    message: entry.message,
    name: entry.name,
    stack: entry.stack,
    version: import.meta.env.VITE_VERSION,
    path: location.pathname,                     // NOT location.href: it may carry parameters
    context: { useCase: entry.context?.useCase, fields: entry.context?.fields }
    // NEVER: form values, user names, personal identifiers, tokens
  };

  navigator.sendBeacon('/api/errors', JSON.stringify(safe));
}

Two details of the code: location.pathname instead of location.href avoids sending query parameters that may contain searches or identifiers; and sendBeacon sends without blocking and works even while the page is closing, which is exactly when many errors occur.

Warning. Do not send personal data to an external service without having covered it in your privacy notice and without knowing where it is stored. In a learning project with fictional data the risk is nil; in a real product, switching on a tracking service without more thought is a compliance problem, not a technical detail.

  1. Field metrics with web-vitals

In 09-01 you learned the distinction that avoids the most confusion: lab (your Lighthouse, your machine, your network) versus field (real users, real devices, real networks). Field numbers are always worse, and they are the ones that matter.

npm install web-vitals
// src/util/vitals.js
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals';

function send(metric) {
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    name: metric.name,                 // LCP, INP, CLS, TTFB
    value: Math.round(metric.value),
    rating: metric.rating,             // good | needs-improvement | poor
    path: location.pathname,
    version: import.meta.env.VITE_VERSION,
    connection: navigator.connection?.effectiveType ?? 'unknown'
  }));
}

if (import.meta.env.PROD) {
  onLCP(send); onINP(send); onCLS(send); onTTFB(send);
}

How to read the results, which is where the value is:

Observation What it means What to do
Lab 1.9 s, field p75 4.2 s Your users have worse devices and networks Throttle harder when measuring locally
CLS good on desktop, bad on mobile Something reflows only on small screens Review with the device emulator
INP bad on one route only One particular screen does excessive work Profile that screen (09-02)
Degradation after a deployment A specific regression Compare by version: that is why it is sent

That last point is what justifies the whole effort: sending the version with every metric lets you attribute a regression to a specific deployment. Without it, all you know is that it got worse at some point.

And a privacy note: performance metrics are not personal data as long as they include no identifiers. Do not add a user identifier "so we can correlate"; the 75th percentile does not need one.

  1. Updating an already-installed PWA

This is the most specific problem in deploying a PWA, and it produces an exasperating situation: the user has the application installed, you deploy a fix, and they keep seeing the old version. They reload, and nothing.

The cause is in the service worker lifecycle (07-05):

stateDiagram-v2
    [*] --> Installing: a different sw.js is detected
    Installing --> Installed: install completed
    Installed --> Waiting: an active SW is controlling tabs
    Waiting --> Activating: skipWaiting() or ALL tabs are closed
    Activating --> Active: activate completed
    Active --> [*]

The "Waiting" state is the problem. By default, a new service worker waits for all the application's tabs to close. And since many people never fully close an installed PWA, that wait can last days.

The complete solution, in three pieces.

Piece 1 · The service worker allows skipping the wait on demand:

// sw.js
const VERSION = 'orbita-v7';

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL_ASSETS)));
  // No automatic skipWaiting(): the user decides
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys()
      .then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
      .then(() => self.clients.claim())
  );
});

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});

Piece 2 · The application detects there is a new version and says so:

// src/util/updates.js
export async function watchForUpdates() {
  if (!('serviceWorker' in navigator)) return;
  const registration = await navigator.serviceWorker.register('/sw.js');

  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;
    newWorker.addEventListener('statechange', () => {
      if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
        showUpdateNotice(() => {
          newWorker.postMessage({ type: 'SKIP_WAITING' });
        });
      }
    });
  });

  // When the new one takes control, reload ONCE
  let reloading = false;
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (reloading) return;
    reloading = true;
    location.reload();
  });

  setInterval(() => registration.update(), 60 * 60 * 1000);   // check every hour
}

Piece 3 · The notice, which must be discreet and accessible:

<div role="status" aria-live="polite" class="update-notice" hidden>
  <p>A new version is available.</p>
  <button type="button" data-action="update">Update now</button>
  <button type="button" data-action="later">Later</button>
</div>

The four rules of updating a PWA:

  1. Never reload without warning. The user may be typing. A surprise reload that loses a form is unforgivable.
  2. The reloading flag is essential. Without it, controllerchange can cause a reload loop: it is a real and very unpleasant bug.
  3. sw.js with Cache-Control: no-cache (section 7). Without that, none of this works because the browser does not even download the new service worker.
  4. Clean up old caches in activate. Otherwise versions pile up and you end up occupying hundreds of megabytes on somebody else's device.

The emergency plan, worth having written down: if you deploy a broken service worker that breaks the application for everybody who has it installed, the way out is to publish a minimal sw.js that unregisters itself and clears every cache:

// emergency sw.js
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', async () => {
  const keys = await caches.keys();
  await Promise.all(keys.map((k) => caches.delete(k)));
  await self.registration.unregister();
  const clients = await self.clients.matchAll();
  clients.forEach((c) => c.navigate(c.url));
});

Save it. The day you need it, you will be grateful.

  1. The launch checklist

The milestone's final deliverable. You walk through all of it, tick it off, and save it signed and dated in docs/launch.md.

18.1 Technical

# Check How
1 npm run verify green Locally and in CI
2 The production build tested locally build + preview
3 Zero secrets in dist/ The grep from section 3
4 HTTPS active and http redirecting curl -I http://… returns 301
5 A single canonical form (with or without www) The other redirects with a 301
6 SPA fallback with status 200 curl -I …/report
7 Correct caching on hashed files curl -I on assets/…
8 index.html and sw.js with no-cache curl -I
9 Compression active Compare Size and Transferred
10 The five security headers present curl -I or a public analyzer
11 CSP with no unsafe-inline and no console errors Navigate through the whole application
12 Performance budget met in production Lighthouse against the real URL
13 Accessibility ≥ 95 in Lighthouse, 0 serious axe violations Same
14 Works offline DevTools in Offline
15 The PWA installs and updates Install, deploy, see the notice
16 Rollback genuinely tested Go back to the previous deployment and forward again

18.2 Content and discoverability

# Check Detail
17 Unique, descriptive <title> "Orbita — Work management for small teams"
18 <meta name="description"> of 150–160 characters What is read in search results
19 <html lang="en"> Accessibility and search engines
20 Complete Open Graph og:title, og:description, og:image (1200×630), og:url, og:type
21 The social card looks right Test it with a link validator
22 Favicon in several sizes 16, 32, 180 (Apple), 192 and 512 (PWA)
23 robots.txt present With Sitemap: pointing at the sitemap
24 sitemap.xml with the public routes Even if there are few
25 A custom, useful 404 page With a link home, not a blank screen
26 Correct manifest.json name, short_name, start_url, display, theme_color, icons
# public/robots.txt
User-agent: *
Allow: /
Sitemap: https://orbita.example/sitemap.xml
<meta property="og:title" content="Orbita — Work management for small teams">
<meta property="og:description" content="Tasks, subtasks, workload per person and change history. No frameworks.">
<meta property="og:image" content="https://orbita.example/og-image.png">
<meta property="og:url" content="https://orbita.example/">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">

18.3 Legal and operational

# Check Detail
27 Privacy notice What data is stored, where, for how long, and with which third parties
28 Cookies and storage If you use analytics or tracking, consent has legal requirements
29 A visible notice about the data "Data is stored only in this browser and is not private from anybody using this device"
30 A license in the repository MIT, Apache 2.0 or whichever you choose
31 Backup Data export available to the user
32 A written rollback procedure docs/operations.md
33 A way to get in touch So somebody can report a bug

Legal warning. Points 27 and 28 are not formalities. In the European Union, informing people about the processing of personal data is an obligation, and the use of cookies or storage that is not strictly necessary requires prior, informed and revocable consent — a banner that only says "accept" does not comply. If your project is an exercise with fictional data and no analytics, the practical risk is nil, but get into the habit of including the notice. If one day you publish a product with real users, this requires specific legal advice: neither this lesson nor any technical documentation is a substitute.

An honest privacy notice for a project like this one is short and takes ten minutes to write:

# Privacy notice — Orbita

**What data is stored.** The tasks, users and history you enter are stored
**only in your browser's local storage**. They are not sent to any server.

**Who can see them.** Anybody with access to this browser and this device.
Orbita offers **no** privacy from other users of the same machine. Do not
enter confidential information.

**Errors.** If something fails, the technical message, the path and the
application version are logged. The content of your tasks is **not** logged.

**How to delete your data.** Settings → Delete all data. You can also clear
the site data from your browser.

**Exporting your data.** Settings → Export (JSON format).

**Contact.** <your contact details>

Last updated: 2026-11-28

Common Mistakes and Tips

Putting a secret in a VITE_ variable. It ends up written into a public file anybody can read with Ctrl+F, and the bots that crawl repositories find it within minutes. Anything granting access to anything goes behind a server function. Check dist/ with grep before every deployment, and automate it in the pipeline.

Not configuring the SPA fallback. Everything works when navigating from the home page and gives a 404 on reload or when opening a shared link — which is exactly how the people you show the project to will arrive.

A long cache on index.html. Users stay on the old version for days and there is no way to force them to update. Hashed files get a one-year cache; the entry point gets no-cache.

Caching sw.js. It produces the most baffling failure there is: the user reloads, clears their history, reinstalls, and keeps seeing the application from three deployments ago.

A badly configured base. On GitHub Pages with a subdirectory, without base: '/repo/' the page loads blank and the console fills with 404s on /assets/…. It is the number one first-deployment failure.

Adding 'unsafe-inline' to the CSP to make it stop complaining. It disables much of the XSS protection, which is exactly what CSP existed to provide. Fix the inline style or script; it is almost always two lines.

Trusting client-side validation alone. Anybody can call your API with curl. With a server, the rules go there too — and your dependency-free domain means it is the same code.

Deploying without having tested the rollback. The day you need it, with the site down and in a hurry, is not the moment to find out how it works. Test it today, in the cold.

Reloading the PWA without warning when there is a new version. The user may be typing. A discreet notice, a button, and a reload only when they ask for it — with the flag that avoids the reload loop.

Sending personal data to an error-tracking service. It is a compliance problem, not a style one. Filter before sending, send pathname and not href, and declare the service in your privacy notice.

Tip · Deploy from day one and often. One deployment a week for two months is boring and safe. One huge deployment on the last day is the recipe for a disaster.

Tip · Have a diagnostics panel in production. A discreet route showing the version, the commit, the build date, the service worker state, the space used and the last logged errors. When somebody tells you "it does not work for me", that screen answers in ten seconds.

Tip · Save a snapshot of your production baseline. Lighthouse against the real URL, with a date. Six months from now you will know whether you improved or got worse, and you will have the evidence.

Tip · Test your deployed site on a real phone. Not on the emulator. On mobile data, not wifi. You will discover things no simulated throttling teaches you.

Exercises

These exercises are milestone H6, part one: the application deployed over HTTPS, its continuous deployment pipeline and the signed checklist.

Exercise 1 — The build and the deployment.

  1. Configure vite.config.js with the right base for your platform, sourcemap, manualChunks and chunkSizeWarningLimit set to your budget.
  2. Create a versioned .env.example and an ignored .env.production, and document in the README which variables are needed.
  3. Build and audit dist/: total size, number of files, and a grep for secrets. Document the results.
  4. Test the whole application with npm run preview before deploying. Note the problems that only show up in production (there will be at least one).
  5. Deploy on the platform of your choice, with your own domain or a platform subdomain, and HTTPS active.
  6. Configure the SPA fallback and check with curl -I that it returns 200 on an internal route.
  7. Configure the complete caching setup per the table in section 7 and verify it with curl -I on a hashed file, on index.html and on sw.js.
  8. Configure the five security headers, with the CSP first in report-only mode and then active, with no unsafe-inline.
  9. Document every decision in docs/deployment.md: which platform, why, and what it implies.

Exercise 2 — Continuous deployment, previews and rollback.

  1. Write .github/workflows/deploy.yml with: prior verification, a build with the commit version, an automatic secrets check, deployment and a smoke check.
  2. Configure the repository's secrets and variables correctly (encrypted secrets, plain variables).
  3. Enable per-PR previews and check the three precautions: noindex, separate data, and awareness that they are public.
  4. Genuinely test the rollback: deploy a visible change, roll back to the previous one, check the site comes back, and publish the new one again. Time how long you take.
  5. Write docs/operations.md with the seven-step rollback procedure adapted to your platform.
  6. Configure error monitoring with personal-data filtering, and demonstrate that a deliberately triggered error arrives with the stack trace and without user data.
  7. Instrument web-vitals sending the version, and collect at least one session of field data.
  8. Implement the PWA update with the three pieces from section 17, and demonstrate it: install the application, deploy a change, and check that the notice appears, that the button updates and that there is no reload loop.

Exercise 3 — The launch.

  1. Walk through the 33 checks from section 18 against your deployed site. Tick each one with evidence (the command run, a screenshot or a URL), not from memory.
  2. Complete the content block: <title>, description, Open Graph with a 1200×630 image, favicons in five sizes, robots.txt, sitemap.xml, a custom 404 page and a complete manifest.json.
  3. Write the privacy notice using the template from section 18.3, adapted to what your application actually does.
  4. Add the license to the repository.
  5. Run Lighthouse against the production URL (not locally) and compare with your budget from 11-01 and your baseline from 11-04. Document the differences and explain them.
  6. Run the site through a security-headers analyzer and document the score.
  7. Open it on a real phone with mobile data and note everything that does not work as you expected.
  8. Sign and date docs/launch.md.

Solutions

Acceptance criteria for exercise 1 — Deployment

# Criterion Verification
1 The site loads over HTTPS curl -sI https://… returns 200
2 HTTP redirects to HTTPS curl -sI http://… returns 301
3 Only one canonical form The other returns 301
4 An internal route reloaded works curl -sI …/report returns 200
5 Hashed file cached for a year cache-control: …max-age=31536000, immutable
6 index.html with no-cache curl -sI
7 sw.js with no-cache curl -sI
8 Compression active Transferred < 40 % of Size
9 The five headers present curl -sI | grep -iE …
10 CSP with no unsafe-inline Inspection + console with no violations
11 Zero secrets in dist/ `grep -rE "(sk_
12 Lighthouse in production meets the budget Report attached

Rubric for exercise 1 (21 points)

Dimension 0 1 2 3
Build Unconfigured It builds base and chunks correct Plus an active size limit
Secrets There is one None, by luck Verified by hand Verified in the automated pipeline
HTTPS and domain No HTTPS With HTTPS With redirection Plus canonical and with progressive HSTS
Routes 404 on reload Fallback With status 200 Plus the API excluded
Caching Unconfigured Something The complete table Verified with curl and documented
Security No headers Some All five CSP with no unsafe-inline and tested in report-only mode
Documentation None Mentions the platform With justification With the implications of each choice

Threshold: 15/21, with a mandatory 3 in "Secrets". A leaked secret invalidates the exercise: it is the only failure in this lesson with real consequences outside the project.

Acceptance criteria for exercise 2 — Continuous deployment and operations

# Criterion Verification
1 A push to main deploys on its own Look at the workflow and the updated site
2 A verify failure blocks the deployment Trigger it
3 A bundle with a fake secret breaks the pipeline Trigger it and remove it
4 The smoke check catches an empty deployment Trigger it
5 Every PR generates a preview Open one
6 Previews are not indexed Check X-Robots-Tag or the meta tag
7 Rolling back takes less than 2 minutes Timed
8 The procedure is written docs/operations.md
9 A production error reaches the service Trigger it
10 That error contains no personal data Inspect the received event
11 The field metrics include the version Inspect the payload
12 The PWA announces the new version Demonstration with the installed application
13 There is no reload loop Reload several times after updating
14 Old caches are cleaned up caches.keys() in the console: only one

Acceptance criteria for exercise 3 — Launch

# Criterion Verification
1 The 33 checks with evidence Ticking the box is not enough
2 The shared link shows the correct card A link validator
3 The favicon shows in the tab Visually, in two browsers
4 The 404 is custom and offers a way out Visit a made-up route
5 robots.txt and sitemap.xml accessible By URL
6 The manifest allows installation The install option appears
7 The privacy notice is honest and specific It describes your application, not a generic template
8 There is a license A LICENSE file
9 Production Lighthouse documented With the differences from local explained
10 The headers score documented With the outstanding items noted
11 Tested on a real phone A list of findings
12 The document signed and dated docs/launch.md

Overall rubric for milestone H6 part one (24 points)

Dimension Weight What is assessed
Build and secrets 5 base, chunks, zero secrets verified automatically
Server 5 Routes, caching, compression, canonical HTTPS
Security 4 The five headers, a real CSP with no unsafe-inline
Continuous deployment 4 Automatic, with verification, smoke check and previews
Operations 3 Rollback tested, written procedure, monitoring with no personal data
PWA 2 Update with a notice, no loop, clean caches
Launch 1 The 33 checks with evidence

Threshold: 17/24. With one condition that cannot be traded away: zero secrets in the bundle. Everything else can be improved in the next iteration; a leaked key cannot.

Self-assessment for milestone H6:

Question Yes / No
Can I open an internal route in a new tab and have it work?
Have I checked with grep that there are no secrets in dist/?
Do I know how long a rollback takes me, because I have timed it?
Will a user with the PWA installed receive my next fix?
Does my privacy notice describe what my application actually does?
Have I opened my site on a real phone with mobile data?
Could I explain each of the five security headers?

Conclusion

Your project no longer lives on your laptop: it is on the internet, over HTTPS, and anybody can use it.

You know what changes when the code leaves your machine — nine differences, each one a source of surprises — and you have the rule that avoids half of them: never deploy anything you have not tested with build and preview. You know the production build from the inside: bundling that reduces requests, code splitting that defers what is not visible, minification that only works well thanks to the static ES modules from 05-04, and filename hashing, the elegant piece that lets you have a one-year cache and immediate updates at the same time — and that explains why index.html is the only one without it. With base configured properly, which is the number one cause of blank first deployments.

You are completely clear on the most important thing in the lesson: there are no secrets in the client. import.meta.env.VITE_* is not a variable: it is a public constant written literally into a file that gets downloaded. You know what can and cannot go in, you know that the faulty reasoning ("it is in an environment variable") confuses where the value was written with where it ends up, and you know that the only correct answer when a key is needed is a server-side intermediary. With the grep verification automated in the pipeline, because good intentions are forgotten and the bots crawling repositories are not.

You know where to host with an honest table that includes what the comparisons do not say: that GitHub Pages does not allow real headers, that Netlify, Vercel and Cloudflare Pages are equivalent for a static site, that your own server is the only one that forces you to understand what is going on, and that free tiers have limits that change. And you know why HTTPS is not optional: without it there is no service worker, no PWA, no secure contexts and no trust.

You know how to solve the problem that surprises everybody — SPA routes — with an index.html fallback returning 200 and not 404, on all four platforms, and with the API route exclusion that stops you receiving HTML where you expected JSON.

You have caching done properly, which is where people go wrong most: a year and immutable for anything hashed, no-cache for the entry point, the service worker and the manifest. You know that no-cache means "revalidate", not "do not store". And you know that a cached sw.js produces the most baffling failure there is: users trapped on a version from three deployments ago no matter how much they reload.

You know how to configure the security headers one by one, understanding them: CSP as the second line of defense against XSS — with connect-src preventing exfiltration, frame-ancestors preventing clickjacking, and the warning that 'unsafe-inline' disables exactly what the policy was there to give — rolled out first in report-only mode; nosniff, Referrer-Policy, Permissions-Policy and HSTS with its progressive max-age because it is hard to undo.

You know what to do about the backend: that not having one is legitimate if it is documented with its limitations; that a backend as a service solves multi-user without writing a server, with the warning that the security rules are yours and one badly written rule exposes all the data; that a twenty-line serverless function is enough to hide a key; and that writing your own is the path in 11-07. And you know, without nuance, that client-side validation never replaces server-side validation — with the reward that your browser-independent domain can be imported as is into Node and run the same R1–R15 on both sides, which is exactly why the first boundary in 11-01 was worth it.

You have continuous deployment with prior verification, the commit version injected — which makes it possible to attribute a regression to a deployment — an automatic secrets check and a smoke test, because a deployment finishing does not mean the site works. With per-branch previews and their three precautions, and with a rollback strategy whose rule is counterintuitive and correct: roll back first, investigate afterwards, because rolling back is reversible and fixing in a hurry is not. With the post mortem whose key question is not who made the mistake but why CI did not catch it.

You know how to monitor in production without becoming a privacy problem: filter before sending, pathname instead of href, sendBeacon because it works while the page is closing, and awareness that a tracking service is a third party receiving your users' data. And you know how to collect field metrics with web-vitals, with the lab-versus-field distinction from 09-01, and with the version attached so you can attribute.

You know how to update an installed PWA, which is the most specific problem in deploying a modern web application: the "waiting" state that can last days, the three pieces that solve it, the four rules — always warn, the flag against the reload loop, no-cache on sw.js, clean up old caches — and the emergency service worker worth having saved before you need it.

And you have the launch checklist with its 33 points in three blocks: technical, content and discoverability, and legal and operational — including the honest and specific privacy notice, the warning that cookie consent has legal requirements an "accept" button does not meet, and the license.

The product is published. And here is what separates a finished project from a project that is also useful for something: nobody knows it exists, nobody knows what decisions lie behind it, and you have not yet practiced how to tell that story. Work you cannot show or defend is worth, in practice, far less than it is. Turning it into something somebody understands in two minutes, that you can demonstrate in five, and that you know how to explain in a technical interview without sounding either insecure or boastful, is Project Presentation and Review.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved