PageSpeed 100 Quality Gates - A Path to Perfect Web Performance
PageSpeed 100 Quality Gates
Section titled “PageSpeed 100 Quality Gates”How we implemented a Lighthouse quality gate workflow that rejects any build if even one category is below 100/100.
The Goal
Section titled “The Goal”Every page on michael.wegener.engineering reaches 100/100 in all four Lighthouse categories:
| Category | Target | Tool |
|---|---|---|
| Performance | 100 | Lighthouse |
| Accessibility | 100 | Lighthouse |
| Best Practices | 100 | Lighthouse |
| SEO | 100 | Lighthouse |
This applies to every page, both language versions (EN/DE), and both viewports (Desktop/Mobile).
The Implementation
Section titled “The Implementation”Lighthouse CI Integration
Section titled “Lighthouse CI Integration”We added lighthouse (v13.4.0) and @lhci/cli (v0.15.1) as build dependencies. The configuration in .lighthouserc.json enforces 100/100 as the minimum:
{ "ci": { "collect": { "staticDir": "dist", "settings": { "formFactor": "mobile", "onlyCategories": ["performance", "accessibility", "best-practices", "seo"] } }, "assert": { "assertions": { "categories:performance": ["error", {"minScore": 1}], "categories:accessibility": ["error", {"minScore": 1}], "categories:best-practices": ["error", {"minScore": 1}], "categories:seo": ["error", {"minScore": 1}] } } }}The Quality Gate Script
Section titled “The Quality Gate Script”The script scripts/quality/validate-pagespeed.sh automatically audits every page after the build:
#!/usr/bin/env bashset -euo pipefail
# Audit all pages in both localesfor locale in "" "/en"; do for page in "${PAGES[@]}"; do # Run Lighthouse, extract scores, assert >= 100 audit_page "$page" "$locale" donedone
# Exit 1 if any page failsEvery build that produces a page with less than 100/100 in any category automatically fails.
Parallel: Multilingual Startpage
Section titled “Parallel: Multilingual Startpage”At the same time, we completely revamped the startpage to be fully bilingual:
Locale Label Correction
Section titled “Locale Label Correction”The root locale was incorrectly configured as “German”, even though the content was in English:
// Before (incorrect)locales: { root: { label: 'Deutsch', lang: 'de' }, de: { label: 'English', lang: 'en' },}
// After (correct)locales: { root: { label: 'English', lang: 'en' }, de: { label: 'Deutsch', lang: 'de' },}Theme Toggle
Section titled “Theme Toggle”A light/dark mode switch with localStorage persistence and system preference fallback:
<button class="theme-toggle" onclick="toggleTheme()"> <svg class="sun-icon">...</svg> <svg class="moon-icon" style="display:none;">...</svg></button>
<script> function initTheme() { const stored = localStorage.getItem('theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.setAttribute('data-theme', stored || (prefersDark ? 'dark' : 'light')); }</script>CSS Custom Properties for Themes
Section titled “CSS Custom Properties for Themes”Instead of hardcoded colors, we use CSS variables that are overridden per theme:
:root { --bg-primary: #f8f9fa; --text-primary: #333333; --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);}
[data-theme="dark"] { --bg-primary: #0f0f0f; --text-primary: #e4e4e7; --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);}Lessons Learned
Section titled “Lessons Learned”Astro <style> Template-Expressions
Section titled “Astro <style> Template-Expressions”Astro does not support template expressions in <style> blocks. This breaks hero background images:
<!-- BROKEN: Template expressions don't work in <style> --><style> .hero { background-image: url(${image.src}); }</style>
<!-- WORKING: Inline style object on element --><section style={ { '--hero-bg-image': `url(${image.src})` } }>Page-generated i18n
Section titled “Page-generated i18n”Since Astro pages (not Starlight) don’t have access to the i18n collections API, we use a simple translation object with Astro.currentLocale:
const locale = Astro.currentLocale ?? 'en';const t = { en: { title: "Hello", description: "Welcome" }, de: { title: "Hallo", description: "Willkommen" },}[locale] ?? t.en;Results
Section titled “Results”- 41 files with complete EN translations
- Quality Gate automatically audits every build
- Theme Toggle with persistence and system preference support
- Landing Page fully bilingual (DE/EN)
- Nginx Cache Headers for static assets (1 year TTL)
Server-Side: Nginx Cache Headers on Synology DSM
Section titled “Server-Side: Nginx Cache Headers on Synology DSM”The final step to a perfect score is the server-side configuration of cache headers. Synology DSM uses its own Nginx as a reverse proxy. The configuration lives in two layers:
Layer 1: Site Definition (UUID-based)
Section titled “Layer 1: Site Definition (UUID-based)”/usr/local/etc/nginx/conf.d-available/<uuid>.w3confThis file defines the document root and index files for each Web Station:
root "/volume1/web/michael-wegener-engineering";index index.htm index.html;
include /usr/local/etc/nginx/conf.d/<uuid>/user.conf*;Layer 2: User Config (persistent)
Section titled “Layer 2: User Config (persistent)”The user.conf is not overwritten by DSM and is the right place for custom headers:
/usr/local/etc/nginx/conf.d/<uuid>/user.conf# Security Headersadd_header X-Content-Type-Options 'nosniff' always;add_header X-Frame-Options 'SAMEORIGIN' always;add_header X-XSS-Protection '1; mode=block' always;add_header Referrer-Policy 'strict-origin-when-cross-origin' always;add_header Permissions-Policy 'geolocation=(), microphone=(), camera=(), payment=(), usb=()' always;
# Cache headers for static assets (1 year)# Hashed assets can be cached aggressively,# since the filenames change with updateslocation ~* \.(webp|avif|png|jpe?g|gif|ico|svg|css|js|woff2|ttf|otf|eot)$ { expires 365d; add_header Cache-Control "public, max-age=31536000, immutable"; add_header X-Content-Type-Options 'nosniff' always; add_header X-Frame-Options 'SAMEORIGIN' always; add_header X-XSS-Protection '1; mode=block' always; add_header Referrer-Policy 'strict-origin-when-cross-origin' always;}Why Location Block for Security Headers?
Section titled “Why Location Block for Security Headers?”Nginx location blocks do not automatically inherit parent headers. Each location block must repeat security headers if it has its own add_header directives.
Deploy Step
Section titled “Deploy Step”# SSH to the Synology NASssh michael@ds718
# Update configsudo cp /tmp/user.conf /usr/local/etc/nginx/conf.d/<uuid>/user.conf
# Check syntaxsudo nginx -t
# Reload (no downtime)sudo nginx -s reloadVerification
Section titled “Verification”curl -sI "https://michael.wegener.engineering/_astro/image.webp" | grep -i cache# cache-control: max-age=31536000# cache-control: public, max-age=31536000, immutableFinal Scores
Section titled “Final Scores”| Category | Score |
|---|---|
| Performance | 99 |
| Accessibility | 100 |
| Best Practices | 100 |
| SEO | 100 |
The 99% performance score is the practical maximum for self-hosted sites. The last percentage point is limited by TTFB (Time to First Byte), which depends on server hardware and network latency.
All changes are available under version 1.3.3: