The four DORA metrics are green and the previous module closed by saying that every decision taken so far was reasoned but none of them is universal. This module puts them through different contexts, and it starts with the closest one: a frontend web application. It is the ideal case for the bridge because the protagonist has spent five lessons backstage. apps/web appeared in the ci.yml of 02-02, it was built with npm run build in 02-03, it was uploaded to S3 with CloudFront invalidation in 03-02 and it travelled in the matrix of 04-04, but always as the API's companion: we have never looked at what makes it distinctive. And it is rather distinctive. A frontend does not deploy a process, it deploys files; its configuration is baked in at build time rather than at start-up — which collides head-on with "build once, deploy many times" from 02-06; its performance and its accessibility are part of the product and can be measured in the pipeline; and its rollback works in a way the API cannot afford. In this lesson we walk through its pipeline end to end, we add the four quality gates that only make sense in the browser, we resolve the configuration conflict, and we finish by looking at the other web case — a server-rendered application — to see what is recovered and what is lost.
Contents
- What this context has that the API did not
- The
apps/webpipeline end to end - The Vite build: what exactly it produces
- Bundle budget as a quality gate
- Configuration: build-time versus runtime
- Cache and invalidation: hashed assets and an uncached
index.html - E2E tests with Playwright against the preview
- Visual regression, accessibility and Lighthouse CI
- Atomic deployment and frontend rollback
- The other web case: server-side rendering
- Case summary
- Common Mistakes and Tips
- Exercises
- Conclusion
- What this context has that the API did not
Before writing a line of YAML it is worth being precise about the differences, because everything else follows from them:
apps/api (the familiar one) |
apps/web (this case) |
|
|---|---|---|
| What gets deployed | A running process (ECS task) | A set of static files |
| Where the code runs | On a server we control | In the user's browser, unknown version and network |
| Configuration | Environment variables read at start-up | Baked in at build time, unless you do something about it |
| Version coexistence | Minutes, during the rolling update | Hours or days: tabs left open on the old version |
| Rollback | Redeploy the previous digest: 4 min | Repoint to the previous folder: seconds |
| Secrets | There are some, in the secrets manager | There can be none: everything is public |
| What "it works" measures | p95 latency, 5xx error rate | That plus size, perceived performance, accessibility |
Two rows deserve an immediate comment. The secrets one is absolute: anything that goes into the bundle is readable by whoever opens the browser's developer tools, so an API key "just for the frontend" is a published key. And version coexistence is the one that surprises people most: when rollback.yml returns the API to a previous digest, within four minutes not a single old task remains; but a user with a tab open since yesterday is still running yesterday's JavaScript against today's API. The frontend is an old client you cannot force to update, and that is exactly the idea the next lesson will take to its extreme with the mobile app.
- The
apps/web pipeline end to end
apps/web pipeline end to endThe full graph, with the new stages marked against what already existed:
flowchart TD
PR["Pull request<br/>touches apps/web"] --> L["lint + tsc + unit tests<br/>02-04, 02-05"]
L --> B["Vite build<br/>+ bundle budget"]
B --> P["Publish preview<br/>pr-482.preview.reservalia.app"]
P --> E2E["Playwright E2E"]
P --> VIS["Visual regression"]
P --> A11Y["axe + Lighthouse CI"]
E2E --> G{"Quality gate"}
VIS --> G
A11Y --> G
G -->|green| M["Merge to main"]
M --> S["Deploy staging<br/>S3 + invalidation"]
S --> PROD["Deploy prod<br/>by promoting the same build"]
What you already know still holds exactly as it is and we will not repeat it: the triggers and the runner (02-02), the installation with npm ci and cache (02-03, 04-02), the prepare-node composite action (04-05), the paths and concurrency that stop this running when the PR only touches the API (02-07), OIDC authentication (03-02, 04-03) and the security job (04-03). What is new is the four boxes in the middle and the shape of the artifact. Let us start there.
- The Vite build: what exactly it produces
$ npm run build --workspace apps/web
vite v5.4.2 building for production...
✓ 1,284 modules transformed.
dist/index.html 0.62 kB │ gzip: 0.38 kB
dist/assets/index-B7fK2p1x.css 41.20 kB │ gzip: 7.94 kB
dist/assets/index-Ca9mQ04d.js 188.53 kB │ gzip: 61.02 kB
dist/assets/schedule-Dk1x77Ze.js 94.11 kB │ gzip: 28.40 kB
✓ built in 6.42sThree observations that govern everything else. First: the names carry a hash of the content (index-Ca9mQ04d.js). If the content changes, the name changes; if it does not change, the name is identical across builds. That is what will make the caching strategy in section 6 possible and it is, at bottom, the same idea as the immutable digest from 02-06 applied to files. Second: index.html carries no hash — it has to live at a fixed URL — and it contains the references to the files that do. It is the only mutable file in the set, and that is why it is the one that is not cached. Third: schedule-Dk1x77Ze.js is split out because the schedule view is loaded on demand; the size that matters is not the total but what the user downloads on the first visit.
The artifact in this case is, then, the contents of dist/, and it is uploaded with actions/upload-artifact so the following jobs can consume it without rebuilding it. Just as in 02-06 with the image: you build once.
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare-node # 1
- run: npm run build --workspace apps/web
- run: npx size-limit --json > size.json # 2
- uses: actions/upload-artifact@v4
with:
name: web-dist-${{ github.sha }} # 3
path: apps/web/dist
retention-days: 7- The composite action from 04-05 handles the Node checkout,
npm ciand the cache: it is not rewritten here. - The budget is calculated on the build just produced, not on an estimate. We look at it in the next section.
- The artifact name includes the commit SHA, so the deployment job downloads exactly the build that passed the tests and not one that has been rebuilt. Rebuilding in order to deploy is the anti-pattern that 02-06 called "building twice".
- Bundle budget as a quality gate
A frontend degrades in a very specific way: nobody adds 400 kB in one go, but twenty PRs adding 20 kB each certainly do. In six months the first load goes from 61 kB to 180 kB compressed and the team finds out through a complaint, not through a measurement. A size budget turns that degradation into a red check, which is exactly what we did with coverage in 02-04 and with new debt in the quality gate of 02-05.
// apps/web/.size-limit.json
[
{
"name": "Initial load (JS)",
"path": ["dist/assets/index-*.js"],
"limit": "65 kB",
"gzip": true
},
{
"name": "Initial load (CSS)",
"path": ["dist/assets/index-*.css"],
"limit": "10 kB",
"gzip": true
},
{
"name": "Schedule view (lazy)",
"path": ["dist/assets/schedule-*.js"],
"limit": "30 kB",
"gzip": true
}
]Four decisions inside that file. It is measured compressed (gzip: true) because that is what travels over the network; measuring the uncompressed file inflates the number and misaligns the signal. There is a budget per group, not a single global one: if there were only a total, moving weight from the initial load to a lazy view — which is a genuine improvement — would be indistinguishable from doing nothing. The limits are set a little above the current value, not at the exact value: a budget that trips on every change gets disabled within a week. And the limit is a product decision, not a technical one: 65 kB of initial JS is the translation of "the schedule has to open in under two seconds on the mobile of a hairdresser's receptionist on so-so 4G".
When the PR goes over budget, the check fails with an actionable message:
Initial load (JS)
Size limit: 65 kB
Size: 71.4 kB with all dependencies, minified and gzipped
✗ Package size limit has exceeded by 6.4 kBDiego: "And if I genuinely need to go over the limit?" Marta: "Then you raise the limit in the same PR, with the new number in plain sight of the reviewer. What I do not want is for it to be exceeded without anybody seeing it."
That is what makes a budget sustainable: it is not a prohibition, it is a forced conversation. The same philosophy as the flaky quarantine policy from 02-04.
- Configuration: build-time versus runtime
Here is the head-on collision with 02-06. Vite replaces references to import.meta.env.VITE_* with their value during compilation:
// apps/web/src/api/client.ts
export const BASE_API = import.meta.env.VITE_API_URL; // replaced at build timeIf VITE_API_URL is https://api.staging.reservalia.app at build time, that text is written inside the JavaScript. The artifact is no longer neutral: it is the staging artifact. And promoting to production the same build that was validated in staging — the rule that holds up half this course — becomes impossible, because it would point at the wrong API. There are two ways out, and it is worth seeing both with their costs:
| Build per environment | Runtime configuration (/config.json) |
|
|---|---|---|
| How it works | Compiled once per environment with its variables | Compiled just once; at start-up the app does fetch('/config.json') |
| Promotion by artifact | ❌ No: what was tested in staging is not what is deployed | ✅ Yes: the same dist/ goes to all three environments |
| Pipeline time | 3 builds (or N with more environments) | 1 build |
| Risk | A prod build failure staging never saw | One extra request before rendering |
| Changing the config | Requires rebuilding and redeploying | Edit a file and invalidate its path |
| Complexity in the code | None | The app has to start after resolving the config |
| When to choose it | Few environments and very stable config | When you want real promotion by artifact |
Reservalia picks the second, consistent with everything before. The minimal implementation:
// generated at deployment time, not stored in the repository
{
"apiUrl": "https://api.reservalia.app",
"environment": "prod",
"sentryDsn": "https://public@sentry.reservalia.app/2",
"version": "1.14.0"
}// apps/web/src/config.ts
export type Config = { apiUrl: string; environment: string; sentryDsn: string; version: string };
export async function loadConfig(): Promise<Config> {
const r = await fetch('/config.json', { cache: 'no-store' }); // 1
if (!r.ok) throw new Error('Could not load the configuration');
return r.json();
}// apps/web/src/main.tsx
const config = await loadConfig(); // 2
initialiseSentry(config);
createRoot(document.getElementById('root')!).render(<App config={config} />);cache: 'no-store'is essential: if the browser cachesconfig.json, a configuration change never arrives. It is the same reasoning as withindex.htmlin the next section.- The application starts after resolving the configuration. The cost is one serial request before the first render; it is offset by a
<link rel="preload" href="/config.json">in the HTML so the browser requests it in parallel with the JS.
And one non-negotiable rule: config.json contains public values only. The client sentryDsn is public by design; a service key is not. If something cannot appear in a screenshot of the inspector, it does not belong here and it probably has to be resolved by the API.
- Cache and invalidation: hashed assets and an uncached
index.html
index.htmlLesson 03-02 left the web deployment broadly resolved: sync dist/ to S3 and invalidate CloudFront. What we did not see is that not all files are cached the same way, and that getting it wrong produces the most baffling bug in frontend work: a user with the new HTML requesting a JS file that no longer exists, or the other way round.
# 1 · hashed assets: eternal cache, never invalidated
aws s3 sync apps/web/dist s3://reservalia-web-prod --delete \
--exclude "index.html" --exclude "config.json" \
--cache-control "public, max-age=31536000, immutable"
# 2 · index.html and config.json: never cached
aws s3 cp apps/web/dist/index.html s3://reservalia-web-prod/index.html \
--cache-control "no-cache, must-revalidate"
aws s3 cp config.prod.json s3://reservalia-web-prod/config.json \
--cache-control "no-cache, must-revalidate"
# 3 · minimal invalidation
aws cloudfront create-invalidation --distribution-id "$CF_DIST" \
--paths "/index.html" "/config.json"- A year of cache plus
immutablefor the hashed assets. It is safe precisely because the name depends on the content: if the content changes, the URL is a different one and there is nothing to invalidate. The browser does not even ask whether it has changed. index.htmlis always revalidated, because it is the only file whose URL is fixed and whose content changes. It is the entry point that says which assets have to be loaded.- The invalidation covers two paths, not
/*. Invalidating everything costs money above the monthly quota, takes longer and adds nothing: hashed assets never need invalidating. This change alone brought Reservalia's web deployment time down from 4 minutes to 40 seconds.
The order matters and it is counter-intuitive: the new assets go up first, and only afterwards the index.html that references them. The other way round there would be a window in which the new HTML asks for files that do not yet exist. And notice the --delete in the first command: it removes from S3 anything no longer in dist/, which would break users with a tab open since yesterday. That is resolved in section 9.
- E2E tests with Playwright against the preview
Per-PR preview environments appeared in 02-07 as a way for Marta to see the changes without starting anything up. Now they earn their second use: they are the URL the browser tests run against. Each PR publishes to pr-482.preview.reservalia.app and the three following jobs target that address.
// apps/web/e2e/booking.spec.ts
import { test, expect } from '@playwright/test';
test('a customer books a free slot from the schedule', async ({ page }) => {
await page.goto('/businesses/demo/schedule'); // 1
await expect(page.getByRole('heading', { name: 'Schedule' })).toBeVisible();
await page.getByRole('button', { name: '25 October' }).click();
await page.getByRole('button', { name: '10:30' }).click(); // 2
await page.getByLabel('Name').fill('Fictitious Customer 1');
await page.getByLabel('Phone').fill('+34 600 000 001');
await page.getByRole('button', { name: 'Confirm booking' }).click();
await expect(page.getByText('Booking confirmed')).toBeVisible(); // 3
});- The path is relative: the base URL comes from Playwright's configuration via a variable, so the same test runs against the PR preview, against staging or against localhost. The
demobusiness and its schedule come from the synthetic data of 04-06, which is why10:30is a known slot and not a coincidence. - Selectors by role and accessible text, not by CSS class nor by
data-testidwhere it can be avoided. It has two advantages: the test does not break when styles change — one of the great sources of flakiness in 02-04 — and it also fails if the button stops being accessible, so the functional test protects the semantics too. - The waits are on visible state, never
waitForTimeout. Playwright retries theexpectuntil the time limit; a fixedsleepis the recipe for a flaky test.
As to how many of these you should have, the pyramid from 02-04 still rules: between six and ten journeys, the ones that make money (book, cancel, view the day's schedule, take payment). Each E2E test costs between 20 and 60 seconds and is the most fragile layer; the temptation to write forty of them is paid for in pipeline time (04-04) and in false reds.
- Visual regression, accessibility and Lighthouse CI
Three more gates that only exist in the browser. The key to none of them being unbearable is how the threshold is configured, and there all three follow the same idea as the "clean new code" quality gate from 02-05.
Visual regression. A screenshot of a component or a view is captured and compared against a versioned reference image:
await expect(page.getByTestId('appointment-card')).toHaveScreenshot('appointment-card.png', {
maxDiffPixelRatio: 0.01, // 1 · tolerance
mask: [page.getByTestId('clock')], // 2 · dynamic areas
});- A small but non-zero tolerance: font antialiasing varies between runs and an exact comparison produces random reds.
- Everything that changes on its own is masked: clocks, random avatars, animations. Without this, visual regression is the new flaky test. And one operational precaution: the screenshots are generated inside the runner's container, never on Nuria's laptop, because system fonts differ and every reference would come out wrong.
Automated accessibility with axe. It runs over the main views within the same Playwright session:
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']) // 1
.analyze();
expect(results.violations).toEqual([]); // 2- It is scoped to the criteria the team has committed to meeting. Turning everything on at once over an existing application gives two hundred violations and the check gets ignored the next day.
- Zero violations on the covered views is achievable if it is adopted view by view. And it is worth saying what the pipeline cannot do: axe detects around a third of real accessibility problems — contrast, labels, roles, tab order — and it is no substitute for a manual review with a screen reader. It is a net that catches regressions, not a certificate.
Lighthouse CI as a performance gate.
// apps/web/lighthouserc.json
{
"ci": {
"collect": { "url": ["https://pr-482.preview.reservalia.app/businesses/demo/schedule"],
"numberOfRuns": 3 },
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
}
}
}
}numberOfRuns: 3 is what makes this gate usable: a single measurement on a shared runner has noise of ±10 points, and with the median of three the noise drops enough for a red to mean something. Even so, the golden rule is do not set thresholds at the edge of the current value; if today you score 0.88 for performance, the threshold goes at 0.85 and is raised when things improve. A gate that flickers ends up being disabled, and then it protects nothing.
- Atomic deployment and frontend rollback
The --delete from section 6 has an unpleasant consequence. A user loaded index.html twenty minutes ago; you deploy; their tab, on navigating to the schedule, asks for schedule-Dk1x77Ze.js… which has just been deleted. Blank screen. The solution is to deploy by version and never overwrite:
VERSION="$GITHUB_SHA" # 1
aws s3 sync apps/web/dist "s3://reservalia-web-prod/v/$VERSION/" \
--cache-control "public, max-age=31536000, immutable" # no --delete
aws s3 cp "s3://reservalia-web-prod/v/$VERSION/index.html" \
s3://reservalia-web-prod/index.html \
--cache-control "no-cache, must-revalidate" # 2
aws cloudfront create-invalidation --distribution-id "$CF_DIST" --paths "/index.html"- Each build lives in its own
v/<sha>/folder and is never deleted at deployment time. The old assets remain available for old tabs; an S3 lifecycle rule removes them after 30 days. - The deployment consists of copying a single file: that version's
index.htmlto the root. That copy is the atomic operation — either the old HTML is there or the new one, never a mixture — and it is what makes the whole thing safe.
From that comes the fastest rollback in the course:
aws s3 cp "s3://reservalia-web-prod/v/$PREVIOUS_SHA/index.html" \
s3://reservalia-web-prod/index.html --cache-control "no-cache, must-revalidate"
aws cloudfront create-invalidation --distribution-id "$CF_DIST" --paths "/index.html"Seconds, not the four minutes of rollback.yml (03-05), because nothing has to be started up: the previous files never went away. With two honest caveats. First, the CloudFront invalidation takes between 30 and 60 seconds to propagate to every point of presence, so "seconds" means under a minute, not instant. And second, if the new frontend depended on an API change, reverting only the frontend is not enough: that is why the backward-compatibility rule from 03-04 applies here too, and the API must keep serving the previous version of the web app. It is, once again, expand and contract (04-06) applied to a different contract.
For progressive rollout, the equivalent of the canary from 03-04 is done with an edge function that decides which index.html to serve based on a cookie or a percentage of requests. Reservalia has not needed it: with feature flags (03-05) inside the single bundle, 90% of cases are covered with far less machinery.
- The other web case: server-side rendering
If instead of a Vite SPA the web app were a server-rendered Next.js application, what changes? Less than it seems, and what does change we have already seen:
| Aspect | Static SPA (apps/web) |
SSR (Next.js) |
|---|---|---|
| Artifact | A folder of files | A container image, like the API |
| Deployment | Copy to S3 + invalidate | Rolling update on ECS (03-04) |
| Configuration | /config.json at runtime |
Environment variables at start-up… for the server; the client's are still baked in |
| Rollback | Repoint index.html: seconds |
Previous digest: 4 min (03-05) |
| Secrets | None possible | Yes on the server, never in what is sent to the browser |
| Scaling | CDN, there are no servers | Tasks and autoscaling, with their cost |
| Quality gates | Bundle, E2E, visual, axe, Lighthouse | Exactly the same |
The reading is the one that orders the whole module: as soon as you get a running process back, you get the module 3 problems back along with their already-written solutions — artifact by digest, health checks, rolling update, rollback in four minutes — and in exchange you lose the zero-cost atomic deployment. What does not change is the specifically frontend half: the bundle budget, the browser tests, accessibility and Lighthouse remain identical, because the user's browser does not know who generated the HTML. And a nuance appears that catches a lot of people out: in Next.js there are still variables baked into the client bundle (the NEXT_PUBLIC_* ones), so the problem from section 5 does not disappear with SSR, it just shrinks to the part that travels to the browser.
- Case summary
| Context | React/Vite SPA served from S3 + CloudFront; 340 businesses; users on mobiles with variable networks |
| What still holds as-is | CI (02), single artifact and promotion (02-06), OIDC (03-02), security (04-03), prepare-node (04-05) |
| Decision 1 | Configuration at runtime via /config.json, to preserve promotion by artifact |
| Decision 2 | Eternal cache for hashed assets, no-cache for index.html; invalidation of 2 paths |
| Decision 3 | Deployment into a v/<sha>/ folder with an atomic copy of the HTML; 30-day retention |
| Decision 4 | Four new gates: bundle budget, Playwright E2E, visual regression, axe + Lighthouse |
| Cost | +2 min 40 s of pipeline on PRs touching apps/web; ~8 h of initial setup work |
| Effect on DORA | Lead time unchanged; frontend time to restore: from 4 min to <1 min; frontend change failure rate −1.2 pp |
| What you take to any project | What gets measured does not degrade: budgets and thresholds turn slow decay into a red check |
Common Mistakes and Tips
Mistake 1: rebuilding the bundle in the deployment job instead of downloading the artifact that passed the tests. That is "building twice" (02-06) and it only takes one transitive dependency changing to deploy something different from what was validated. Mistake 2: putting secrets in VITE_* variables, thinking minification hides them; they are in plain text in the public bundle.
Mistake 3: invalidating /* on every deployment. It costs money, takes minutes and is unnecessary if the assets carry a hash. Mistake 4: caching index.html, which leaves the user loading the previous version for hours and makes the deployment "invisible". Mistake 5: using --delete without versioned folders, which breaks open tabs by deleting the old assets.
Mistake 6: forty E2E tests instead of eight journeys that make money; the pipeline doubles and false reds train the team to ignore red. Mistake 7: Lighthouse thresholds at the edge of the current value and with a single run, which produce a flickering gate. Mistake 8: enabling every axe rule at once on an existing application: two hundred violations is equivalent to zero.
Tip 1: raise the bundle budget in the same PR that needs it, never in a separate one. Tip 2: generate the reference screenshots in the runner's container, never on a laptop. Tip 3: include the version in config.json and show it in the application footer; it will tell you in two seconds which build a user reporting a failure is looking at. Tip 4: apply backward compatibility to the API with respect to the old frontend too, because there will always be tabs from yesterday.
Exercises
Exercise 1
Nuria deploys the web app at 12:05. At 12:07, three businesses report a blank screen when opening the schedule; the browser console shows Failed to load module script: schedule-Dk1x77Ze.js (404). Users opening the web app for the first time have no problem at all. Explain the exact mechanism of the failure, why it affects only some users and which two pipeline changes eliminate it.
Exercise 2
The team wants a single apps/web build to be tested in staging and promoted to production without rebuilding, but the API URL differs in each environment and today it is injected with VITE_API_URL. Design the complete solution: what changes in the code, what the pipeline produces, where each environment's configuration lives and what cache header it carries. Also point out two new risks your design introduces.
Exercise 3
A PR raises the "Initial load (JS)" budget from 65 kB to 96 kB and the description says: "needed for the new charting library". The check passes green because the PR itself modifies the limit. Is this a flaw in the gate's design? Argue your answer and propose what you would add to the pipeline and to the process.
Solutions
Solution 1. The mechanism has three stages. (1) The affected users loaded index.html before the deployment — their tab has been open since 11:40 — so the HTML they hold in memory references the previous version's assets, among them the lazy chunk schedule-Dk1x77Ze.js. (2) The deployment ran aws s3 sync --delete, which removes from S3 everything not in the new dist/; since the content of the schedule view changed, its hash changed and the old file disappeared. (3) At 12:07 those users navigate to the schedule for the first time, the browser requests the lazy chunk — which was not downloaded on initial load, precisely because it is lazy — and receives a 404. The module does not load, React cannot render the route and the screen stays blank. It affects only them because anyone arriving after 12:05 receives the new index.html, which references the new assets, which do exist.
Notice a detail that explains the two-minute delay: the problem does not show up on deployment but on navigation, because lazy chunks are requested on demand. With a monolithic initial load the failure would have been immediate or non-existent; with code splitting it lies dormant in every open tab.
The two changes. (a) Deployment into versioned folders: upload each build to v/<sha>/ without --delete, so the old assets remain while there are tabs asking for them, with an S3 lifecycle rule that deletes them after 30 days. The deployment becomes the atomic copy of that version's index.html to the root. (b) Detection and recovery in the client: catch the dynamic module loading error and, when it happens, reload the page once — which will fetch the new index.html and its assets — with a marker in sessionStorage to avoid a reload loop. The first measure removes the cause; the second covers the residual case on day 31 and that of a user with a month-old tab. As reinforcement, showing the version from config.json in the footer helps support diagnose: the user reads out a number and you know at once which build they have.
Solution 2. In the code: every reference to import.meta.env.VITE_API_URL is removed and replaced by the config.ts from section 5, which does fetch('/config.json', { cache: 'no-store' }) before mounting the application; main.tsx starts asynchronously and the config is propagated through React context instead of being imported as a constant. It is worth adding a runtime type check over the JSON received: if apiUrl is missing, an explicit error on screen is better than an undefined propagating through every call.
What the pipeline produces: a single web-dist-<sha> artifact with dist/, built without any environment-specific environment variable. That artifact is the one deployed to staging, the one that passes the E2E tests and the one promoted to production without rebuilding, exactly like the image by digest in 02-06.
Where the configuration lives: three files versioned in the repository — apps/web/config/staging.json and prod.json, plus dev.json — which the deployment job copies to the bucket root under the name config.json. They live in the repository because they contain only public values and so they stay under review, with history and with CODEOWNERS. If some value were not public, it would not go here: the API would resolve it after authenticating. Headers: config.json with no-cache, must-revalidate and present in the invalidation list alongside index.html; the hashed assets with max-age=31536000, immutable.
Two new risks. (1) A request on the critical path: the application renders nothing until config.json responds, so a failure or slowness in that request is a blank screen. It is mitigated with <link rel="preload"> to request it in parallel, with a retry and with a readable error message instead of emptiness. (2) Desynchronisation between the HTML and the config: since they are two files with independent caching, there is a window in which a user has the new bundle and the old config. If a deployment introduces a new mandatory key, that combination fails. It is mitigated by treating the configuration as a contract with backward compatibility — new keys always optional with a default value, and old ones retired one version later — which is expand and contract (04-06) applied to a JSON file. A third, smaller but real risk: since the config is no longer in the bundle, a deployment that copies config.staging.json to production is caught by no compiler; a post-deployment smoke test that requests /config.json and verifies that environment holds the right value is advisable.
Solution 3. It is not a design flaw, it is the design working, but it is incomplete. The purpose of the budget was never to stop the application growing — an application that gains functionality grows — but to stop it growing without anybody deciding to. By forcing the limit to be modified in the same PR, the 31 kB increase appears in the diff, goes to review and stays in the git history with a date, an author and a reason. That is exactly what was wanted: a forced conversation at the moment when it can still be had. Compare it with the alternative of having no gate, in which those 31 kB get in without anybody seeing them and turn up six months later as "the application is slow", with no way of knowing which PR brought them.
What is missing is for that conversation to have enough data. I would add four things. (1) An automatic PR comment with the comparison against main — current metric, previous limit, proposed limit, difference in kB and in estimated download time on 4G — so the reviewer sees the impact in seconds rather than in abstract kilobytes. (2) A CODEOWNERS rule (02-07) on .size-limit.json, so that changing a budget requires Marta's approval and not just that of the colleague reviewing the functionality. (3) Requiring in the PR template that a limit increase comes with the alternatives that were discarded: can the charting library be loaded lazily, only in the view that uses it, leaving the initial load untouched? In this particular case that is almost certainly possible, and it would turn a 31 kB increase in the initial load into a lazy chunk with its own budget. (4) A quarterly review of the budgets alongside real field data, because the definitive signal is not the CI number but the performance as perceived by the receptionist on 4G. With those four pieces, the gate stops being a traffic light that can be painted green and becomes what it should be: a mechanism that makes a decision visible and forces it to be justified.
Conclusion
apps/web has stopped being the API's companion and has shown that a frontend, even sharing a pipeline, a repository and a team, has a physics of its own. It deploys files and not processes, which gives it the cheapest rollback in the course — repointing index.html at a previous v/<sha>/ folder, under a minute — in exchange for demanding a deployment designed to be atomic and not to delete what open tabs are still requesting. Its code runs in somebody else's browser, which forbids any secret and forces you to treat the previous version as a client you cannot force to update. And its configuration is baked in at build time, which collided head-on with the promotion by artifact of 02-06 until we moved it to runtime with /config.json, accepting in exchange one request on the critical path and a JSON contract that also needs backward compatibility. On that base we added the four gates that only make sense in the browser — a bundle budget with size-limit, E2E journeys with Playwright against the PR preview, visual regression with tolerance and masks, and axe plus Lighthouse CI with realistic thresholds and three runs — and we tuned the CloudFront cache until a four-minute invalidation became one of two paths and forty seconds. The SSR case closed the circle by showing that, as soon as there is a running process again, the module 3 mechanisms come back in full, while the specifically frontend half remains identical.
The thread that connects to what comes next is the one in the most uncomfortable row of the first table: the frontend is an old client you cannot force to update, but at least it is enough for the user to reload the page. In the next lesson, Case Study: Mobile Application, that same property is taken to its extreme. In Reservalia Pro — the React Native app the professional uses to manage their schedule — the user decides when they update and may never do it, an app store reviews every version for hours or days before publishing it, and there is no rollback for a version already distributed. Everything we resolved here with a CloudFront invalidation will have to be resolved with code signing, distribution channels, staged rollout by percentage and an API compatibility discipline that is the mobile equivalent of the expand and contract from 04-06.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
