Skip to content

PageSpeed 100 Quality Gates - A Path to Perfect Web Performance

How we implemented a Lighthouse quality gate workflow that rejects any build if even one category is below 100/100.

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).

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 script scripts/quality/validate-pagespeed.sh automatically audits every page after the build:

#!/usr/bin/env bash
set -euo pipefail
# Audit all pages in both locales
for locale in "" "/en"; do
for page in "${PAGES[@]}"; do
# Run Lighthouse, extract scores, assert >= 100
audit_page "$page" "$locale"
done
done
# Exit 1 if any page fails

Every build that produces a page with less than 100/100 in any category automatically fails.

At the same time, we completely revamped the startpage to be fully bilingual:

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' },
}

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>

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);
}

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})` } }>

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;
  • 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:

/usr/local/etc/nginx/conf.d-available/<uuid>.w3conf

This 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*;

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 Headers
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;
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 updates
location ~* \.(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;
}

Nginx location blocks do not automatically inherit parent headers. Each location block must repeat security headers if it has its own add_header directives.

Terminal window
# SSH to the Synology NAS
ssh michael@ds718
# Update config
sudo cp /tmp/user.conf /usr/local/etc/nginx/conf.d/<uuid>/user.conf
# Check syntax
sudo nginx -t
# Reload (no downtime)
sudo nginx -s reload
Terminal window
curl -sI "https://michael.wegener.engineering/_astro/image.webp" | grep -i cache
# cache-control: max-age=31536000
# cache-control: public, max-age=31536000, immutable
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: