diff --git a/.gitea/workflows/build-docs.yaml b/.gitea/workflows/build-docs.yaml
new file mode 100644
index 0000000..f465088
--- /dev/null
+++ b/.gitea/workflows/build-docs.yaml
@@ -0,0 +1,85 @@
+# Gitea Actions: Aggregate openapi.yaml specs + trigger docs-site build
+# Closes leeworks-agents/api-company#10
+
+name: Build Docs Site
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+ schedule:
+ # Re-build daily at 02:00 UTC to pick up spec changes
+ - cron: '0 2 * * *'
+
+jobs:
+ aggregate-specs:
+ name: Aggregate OpenAPI Specs
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout api-company
+ uses: actions/checkout@v4
+ with:
+ path: api-company
+
+ - name: Checkout zip-enrichment
+ uses: actions/checkout@v4
+ with:
+ repository: leeworks-agents/zip-enrichment
+ token: ${{ secrets.GITEA_TOKEN }}
+ path: zip-enrichment
+
+ - name: Checkout holidays
+ uses: actions/checkout@v4
+ with:
+ repository: leeworks-agents/holidays
+ token: ${{ secrets.GITEA_TOKEN }}
+ path: holidays
+
+ - name: Checkout air-quality
+ uses: actions/checkout@v4
+ with:
+ repository: leeworks-agents/air-quality
+ token: ${{ secrets.GITEA_TOKEN }}
+ path: air-quality
+
+ - name: Copy openapi.yaml specs into docs-site
+ run: |
+ mkdir -p api-company/docs-site/public/specs
+ cp zip-enrichment/openapi.yaml api-company/docs-site/public/specs/zip-enrichment.yaml
+ cp holidays/openapi.yaml api-company/docs-site/public/specs/holidays.yaml
+ cp air-quality/openapi.yaml api-company/docs-site/public/specs/air-quality.yaml
+ echo "Specs copied:"
+ ls -la api-company/docs-site/public/specs/
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install docs-site dependencies
+ working-directory: api-company/docs-site
+ run: npm ci
+
+ - name: Build docs-site
+ working-directory: api-company/docs-site
+ run: npm run build
+
+ - name: Log in to container registry
+ run: |
+ echo "${{ secrets.GITEA_TOKEN }}" | docker login registry.leeworks.dev \
+ -u ${{ gitea.actor }} --password-stdin
+
+ - name: Build and push docs-site image
+ working-directory: api-company/docs-site
+ run: |
+ IMAGE="registry.leeworks.dev/leeworks-agents/docs-site"
+ SHA="${{ gitea.sha }}"
+ docker build -t "$IMAGE:$SHA" -t "$IMAGE:latest" .
+ docker push "$IMAGE:$SHA"
+ docker push "$IMAGE:latest"
+ echo "Pushed $IMAGE:$SHA"
+
+ - name: Trigger Flux reconcile (optional)
+ run: |
+ echo "Image pushed. Flux will detect new tag via image automation and re-deploy docs-site."
+ echo "If image automation is not configured, manually run: flux reconcile helmrelease docs-site -n docs-site"
diff --git a/docs-site/.gitkeep b/docs-site/.gitkeep
deleted file mode 100644
index 6aa55af..0000000
--- a/docs-site/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-# placeholder — populated by Phase-4/5 issues
diff --git a/docs-site/Dockerfile b/docs-site/Dockerfile
new file mode 100644
index 0000000..7c8576b
--- /dev/null
+++ b/docs-site/Dockerfile
@@ -0,0 +1,14 @@
+# Build stage
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# Serve with nginx
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
new file mode 100644
index 0000000..a9c5f7a
--- /dev/null
+++ b/docs-site/astro.config.mjs
@@ -0,0 +1,9 @@
+import { defineConfig } from 'astro/config';
+import mdx from '@astrojs/mdx';
+import sitemap from '@astrojs/sitemap';
+
+export default defineConfig({
+ site: 'https://docs.leeworks.dev',
+ integrations: [mdx(), sitemap()],
+ output: 'static',
+});
diff --git a/docs-site/nginx.conf b/docs-site/nginx.conf
new file mode 100644
index 0000000..0476428
--- /dev/null
+++ b/docs-site/nginx.conf
@@ -0,0 +1,25 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Gzip compression
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location ~* \.(js|css|png|jpg|svg|ico|woff2?)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # Health check
+ location /health {
+ return 200 "ok\n";
+ add_header Content-Type text/plain;
+ }
+}
diff --git a/docs-site/package.json b/docs-site/package.json
new file mode 100644
index 0000000..a86f588
--- /dev/null
+++ b/docs-site/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "leeworks-docs-site",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview"
+ },
+ "dependencies": {
+ "astro": "^4.8.0",
+ "@astrojs/mdx": "^3.0.0",
+ "@astrojs/sitemap": "^3.1.0",
+ "redoc": "^2.1.5"
+ },
+ "devDependencies": {
+ "typescript": "^5.4.0"
+ }
+}
diff --git a/docs-site/src/layouts/Base.astro b/docs-site/src/layouts/Base.astro
new file mode 100644
index 0000000..eaece39
--- /dev/null
+++ b/docs-site/src/layouts/Base.astro
@@ -0,0 +1,50 @@
+---
+export interface Props {
+ title: string;
+ description?: string;
+}
+const { title, description = "leeworks.dev API documentation" } = Astro.props;
+---
+
+
+
+
+
+
+ {title} | leeworks.dev APIs
+
+
+
+
+
+ leeworks.dev
+ ZIP Enrichment
+ Holidays
+ Air Quality
+ Blog
+ RapidAPI
+
+
+
+
+
+
+
diff --git a/docs-site/src/pages/air-quality.astro b/docs-site/src/pages/air-quality.astro
new file mode 100644
index 0000000..b05a4cb
--- /dev/null
+++ b/docs-site/src/pages/air-quality.astro
@@ -0,0 +1,31 @@
+---
+import Base from '../layouts/Base.astro';
+
+const apiName = 'air-quality';
+const titles: Record = {
+ 'zip-enrichment': 'ZIP Enrichment API',
+ 'holidays': 'Holidays API',
+ 'air-quality': 'Air Quality API',
+};
+const title = titles[apiName];
+---
+
+
+
+
+
diff --git a/docs-site/src/pages/blog/air-quality-api.mdx b/docs-site/src/pages/blog/air-quality-api.mdx
new file mode 100644
index 0000000..37e46d5
--- /dev/null
+++ b/docs-site/src/pages/blog/air-quality-api.mdx
@@ -0,0 +1,178 @@
+---
+title: "Air Quality API: Real-Time AQI Data for Any Location"
+description: "Access real-time Air Quality Index (AQI) data, PM2.5, PM10, and health recommendations for any city worldwide using the leeworks.dev Air Quality API."
+date: "2026-05-24"
+author: "leeworks.dev"
+tags: ["air-quality", "aqi", "api", "tutorial"]
+---
+
+import Base from '../../layouts/Base.astro';
+
+
+
+
+
+
+
+# Air Quality API: Real-Time AQI Data for Any Location
+
+Whether you're building a fitness app, a travel planner, or a smart home dashboard, air quality data is increasingly essential. The leeworks.dev **Air Quality API** gives you real-time AQI readings, pollutant breakdowns, and health recommendations for any location in the world.
+
+## What Is AQI and Why Does Your App Need It?
+
+The **Air Quality Index (AQI)** is a standardized scale (0–500) that communicates how clean or polluted the air is:
+
+| AQI | Category | Health Implication |
+|-----|----------|-------------------|
+| 0–50 | Good | Air quality is satisfactory |
+| 51–100 | Moderate | Acceptable for most people |
+| 101–150 | Unhealthy for Sensitive Groups | At-risk groups may experience effects |
+| 151–200 | Unhealthy | Everyone may begin to experience health effects |
+| 201–300 | Very Unhealthy | Health alert: serious effects possible |
+| 301–500 | Hazardous | Emergency conditions |
+
+**Use cases for an AQI data API:**
+
+- **Fitness apps** — warn runners when outdoor exercise is unsafe
+- **Travel apps** — show air quality forecasts for destination cities
+- **Real estate platforms** — display neighborhood air quality scores
+- **Smart home apps** — trigger air purifiers based on outdoor AQI
+- **Health tracking apps** — correlate symptoms with air quality data
+- **News and weather apps** — add AQI to daily weather cards
+
+## Quick Start
+
+```bash
+# Get current AQI for a city
+curl "https://aqi.leeworks.dev/v1/current?city=Los+Angeles&country=US" \
+ -H "X-RapidAPI-Key: YOUR_API_KEY"
+```
+
+Response:
+```json
+{
+ "location": {
+ "city": "Los Angeles",
+ "country": "US",
+ "latitude": 34.0522,
+ "longitude": -118.2437
+ },
+ "aqi": 87,
+ "category": "Moderate",
+ "pollutants": {
+ "pm25": 22.4,
+ "pm10": 35.1,
+ "o3": 41.2,
+ "no2": 18.5,
+ "so2": 2.1,
+ "co": 0.4
+ },
+ "health_recommendation": "Unusually sensitive people should consider reducing prolonged outdoor exertion.",
+ "updated_at": "2026-05-24T14:30:00Z"
+}
+```
+
+## By Coordinates (Lat/Long)
+
+```bash
+curl "https://aqi.leeworks.dev/v1/current?lat=48.8566&lon=2.3522" \
+ -H "X-RapidAPI-Key: YOUR_API_KEY"
+```
+
+## Code Examples
+
+### JavaScript
+
+```javascript
+async function getAirQuality(city, country = 'US') {
+ const response = await fetch(
+ `https://aqi.leeworks.dev/v1/current?city=${encodeURIComponent(city)}&country=${country}`,
+ { headers: { 'X-RapidAPI-Key': process.env.RAPIDAPI_KEY } }
+ );
+ return response.json();
+}
+
+const data = await getAirQuality('Denver');
+if (data.aqi > 100) {
+ console.warn(`Air quality in ${data.location.city} is ${data.category}. Consider staying indoors.`);
+}
+```
+
+### Python
+
+```python
+import httpx
+
+def get_aqi(lat: float, lon: float) -> dict:
+ resp = httpx.get(
+ "https://aqi.leeworks.dev/v1/current",
+ params={"lat": lat, "lon": lon},
+ headers={"X-RapidAPI-Key": "YOUR_KEY"},
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+# Example: check AQI before recommending outdoor run
+aqi_data = get_aqi(37.7749, -122.4194) # San Francisco
+if aqi_data["aqi"] <= 100:
+ print("Good to go for a run!")
+else:
+ print(f"Air quality is {aqi_data['category']} — consider indoor exercise.")
+```
+
+### React Hook
+
+```tsx
+import { useState, useEffect } from 'react';
+
+interface AQIData {
+ aqi: number;
+ category: string;
+ health_recommendation: string;
+}
+
+export function useAirQuality(city: string) {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch(`/api/aqi?city=${encodeURIComponent(city)}`)
+ .then(r => r.json())
+ .then(setData)
+ .finally(() => setLoading(false));
+ }, [city]);
+
+ return { data, loading };
+}
+```
+
+## Data Source
+
+The leeworks.dev Air Quality API aggregates data from the **OpenAQ** public dataset — a non-profit platform that collects open air quality data from government agencies worldwide. Data is refreshed hourly.
+
+## Pricing
+
+| Plan | Requests/mo | Price |
+|------|------------|-------|
+| Free | 500 | $0 |
+| Basic | 10,000 | $9/mo |
+| Pro | 100,000 | $19/mo |
+| Ultra | 1,000,000 | $49/mo |
+
+**[Subscribe on RapidAPI →](https://rapidapi.com/leeworks/api/air-quality)**
+
+## Conclusion
+
+Air quality is no longer a niche data point — it's a critical health metric that millions of people check daily. The leeworks.dev **AQI data API** gives your app real-time air quality readings, pollutant breakdowns, and actionable health recommendations with a simple REST interface.
+
+**[Start building for free →](https://rapidapi.com/leeworks/api/air-quality)**
+
+
+
diff --git a/docs-site/src/pages/blog/index.astro b/docs-site/src/pages/blog/index.astro
new file mode 100644
index 0000000..de97a1b
--- /dev/null
+++ b/docs-site/src/pages/blog/index.astro
@@ -0,0 +1,29 @@
+---
+import Base from '../../layouts/Base.astro';
+
+const posts = await Astro.glob('./*.mdx');
+posts.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
+---
+
+
+ Blog Tutorials and news from leeworks.dev
+
+ {posts.map(post => (
+
+
+
{post.frontmatter.date}
+
{post.frontmatter.description}
+
+ ))}
+ {posts.length === 0 &&
No posts yet.
}
+
+
diff --git a/docs-site/src/pages/blog/public-holidays-api.mdx b/docs-site/src/pages/blog/public-holidays-api.mdx
new file mode 100644
index 0000000..72623de
--- /dev/null
+++ b/docs-site/src/pages/blog/public-holidays-api.mdx
@@ -0,0 +1,161 @@
+---
+title: "Public Holidays API: The Free Holiday Calendar API for Any Country"
+description: "Get public holidays for 100+ countries with a single API call. The leeworks.dev Holidays API is perfect for scheduling, calendar apps, and payroll systems."
+date: "2026-05-24"
+author: "leeworks.dev"
+tags: ["holidays", "api", "tutorial"]
+---
+
+import Base from '../../layouts/Base.astro';
+
+
+
+
+
+
+
+# Public Holidays API: The Free Holiday Calendar API for Any Country
+
+Building a scheduling app, payroll system, or booking platform? You need accurate **public holiday data** for every country you serve. The leeworks.dev **Holidays API** gives you that data in milliseconds.
+
+## Why You Need a Holiday Calendar API
+
+Manually maintaining a list of public holidays is a losing battle. Holidays change year to year, differ by country and region, and missing one can mean:
+
+- **Wrong delivery estimates** on e-commerce sites
+- **Incorrect payroll calculations** (overtime on holidays)
+- **Broken calendar apps** that schedule meetings on national holidays
+- **Failed SLA commitments** that assumed business days
+
+A reliable **holiday API** solves this once.
+
+## What the leeworks.dev Holidays API Provides
+
+- Public holidays for **100+ countries**
+- Data updated from Nager.Date's curated public dataset
+- Filter by **country code** (ISO 3166-1 alpha-2), **year**, and **type**
+- Response includes holiday name (localized), date, and type (`public`, `optional`, `observance`)
+- Sub-100ms response time, SQLite-backed
+
+## Quick Start
+
+```bash
+# Get all US public holidays for 2026
+curl "https://holidays.leeworks.dev/v1/holidays?country=US&year=2026" \
+ -H "X-RapidAPI-Key: YOUR_API_KEY"
+```
+
+Response:
+```json
+{
+ "country": "US",
+ "year": 2026,
+ "holidays": [
+ {
+ "date": "2026-01-01",
+ "name": "New Year's Day",
+ "type": "public"
+ },
+ {
+ "date": "2026-07-04",
+ "name": "Independence Day",
+ "type": "public"
+ }
+ ]
+}
+```
+
+## Common Use Cases
+
+### 1. Skip Holidays in Business Day Calculations
+
+```python
+from datetime import date, timedelta
+import httpx
+
+def next_business_day(start: date, country: str = "US") -> date:
+ resp = httpx.get(
+ "https://holidays.leeworks.dev/v1/holidays",
+ params={"country": country, "year": start.year},
+ headers={"X-RapidAPI-Key": "YOUR_KEY"},
+ )
+ holidays = {h["date"] for h in resp.json()["holidays"]}
+
+ current = start + timedelta(days=1)
+ while current.weekday() >= 5 or current.isoformat() in holidays:
+ current += timedelta(days=1)
+ return current
+```
+
+### 2. Display Holiday Badges in a Calendar
+
+```javascript
+async function getHolidayMap(countryCode, year) {
+ const res = await fetch(
+ `https://holidays.leeworks.dev/v1/holidays?country=${countryCode}&year=${year}`,
+ { headers: { 'X-RapidAPI-Key': process.env.RAPIDAPI_KEY } }
+ );
+ const { holidays } = await res.json();
+ // Return a Map of ISO date string → holiday name
+ return new Map(holidays.map(h => [h.date, h.name]));
+}
+
+// Usage in a calendar component
+const holidayMap = await getHolidayMap('GB', 2026);
+const isHoliday = holidayMap.has('2026-12-25'); // true: Christmas Day
+```
+
+### 3. Check If Today Is a Holiday
+
+```typescript
+async function isTodayHoliday(country = 'US'): Promise {
+ const today = new Date().toISOString().split('T')[0];
+ const year = new Date().getFullYear();
+
+ const res = await fetch(
+ `https://holidays.leeworks.dev/v1/is-holiday?country=${country}&date=${today}`,
+ { headers: { 'X-RapidAPI-Key': process.env.RAPIDAPI_KEY! } }
+ );
+ const data = await res.json();
+ return data.isHoliday ? data.name : null;
+}
+```
+
+## Supported Countries (Sample)
+
+| Code | Country | Code | Country |
+|------|---------|------|---------|
+| US | United States | GB | United Kingdom |
+| CA | Canada | DE | Germany |
+| FR | France | JP | Japan |
+| AU | Australia | BR | Brazil |
+| IN | India | MX | Mexico |
+
+...and 90+ more. Use `GET /v1/countries` to see the full list.
+
+## Pricing
+
+| Plan | Requests/mo | Price |
+|------|------------|-------|
+| Free | 500 | $0 |
+| Basic | 10,000 | $9/mo |
+| Pro | 100,000 | $19/mo |
+| Ultra | 1,000,000 | $49/mo |
+
+**[Subscribe on RapidAPI →](https://rapidapi.com/leeworks/api/holidays)**
+
+## Conclusion
+
+Stop hardcoding holiday lists or scraping Wikipedia. The leeworks.dev **public holidays API** gives you accurate, up-to-date holiday data for every country you need — with a simple REST interface and affordable pricing.
+
+**[Get started for free →](https://rapidapi.com/leeworks/api/holidays)**
+
+
+
diff --git a/docs-site/src/pages/blog/zip-code-enrichment-api.mdx b/docs-site/src/pages/blog/zip-code-enrichment-api.mdx
new file mode 100644
index 0000000..bf87261
--- /dev/null
+++ b/docs-site/src/pages/blog/zip-code-enrichment-api.mdx
@@ -0,0 +1,158 @@
+---
+title: "ZIP Code Enrichment API: Add Location Intelligence to Your App in Minutes"
+description: "Learn how to use the leeworks.dev ZIP Code Enrichment API to add city, state, timezone, and demographic data to any postal code lookup."
+date: "2026-05-24"
+author: "leeworks.dev"
+tags: ["zip-enrichment", "api", "tutorial"]
+---
+
+import Base from '../../layouts/Base.astro';
+
+
+
+
+
+
+
+# ZIP Code Enrichment API: Add Location Intelligence to Your App in Minutes
+
+Every time a user types their ZIP code, there's a wealth of data waiting to be unlocked — city name, state, county, timezone, latitude, longitude, and more. The **leeworks.dev ZIP Code Enrichment API** makes it trivially easy to retrieve all of that in a single API call.
+
+## What Is a ZIP Code Enrichment API?
+
+A **postal code demographics API** (or ZIP enrichment API) takes a 5-digit US ZIP code as input and returns structured data about that location. This is useful for:
+
+- **E-commerce** — display the user's city/state after they type a ZIP, skip the state dropdown
+- **Shipping calculators** — determine timezone and region for delivery estimates
+- **Analytics dashboards** — group customers by region, state, or county
+- **Lead scoring** — enrich CRM contacts with location data automatically
+- **Form UX** — auto-fill city/state fields for a smoother checkout experience
+
+## Why Build on leeworks.dev?
+
+Unlike scraping Google Maps or paying for expensive enterprise solutions, the leeworks.dev ZIP Enrichment API:
+
+- Returns **sub-50ms responses** (SQLite-backed, no external dependencies)
+- Provides **100% US ZIP code coverage** using the free USPS/Census dataset
+- Is available on **RapidAPI** with a generous free tier
+- Has a **simple, well-documented REST API** following the OpenAPI 3.1 standard
+
+## Quick Start
+
+### 1. Get Your API Key
+
+Sign up on [RapidAPI](https://rapidapi.com/leeworks/api/zip-enrichment) and subscribe to a plan. The Free tier gives you 500 requests/month.
+
+### 2. Make Your First Call
+
+```bash
+curl -X GET "https://zip.leeworks.dev/v1/lookup?zip=90210" \
+ -H "X-RapidAPI-Key: YOUR_API_KEY" \
+ -H "X-RapidAPI-Host: zip.leeworks.dev"
+```
+
+### 3. Parse the Response
+
+```json
+{
+ "zip": "90210",
+ "city": "Beverly Hills",
+ "state": "CA",
+ "state_full": "California",
+ "county": "Los Angeles",
+ "timezone": "America/Los_Angeles",
+ "latitude": 34.0901,
+ "longitude": -118.4065,
+ "population": 20124
+}
+```
+
+## Code Examples
+
+### JavaScript / Node.js
+
+```javascript
+const response = await fetch('https://zip.leeworks.dev/v1/lookup?zip=10001', {
+ headers: {
+ 'X-RapidAPI-Key': process.env.RAPIDAPI_KEY,
+ 'X-RapidAPI-Host': 'zip.leeworks.dev',
+ },
+});
+const data = await response.json();
+console.log(`${data.city}, ${data.state} (${data.timezone})`);
+// → "New York, NY (America/New_York)"
+```
+
+### Python
+
+```python
+import httpx
+
+resp = httpx.get(
+ "https://zip.leeworks.dev/v1/lookup",
+ params={"zip": "60601"},
+ headers={
+ "X-RapidAPI-Key": "YOUR_API_KEY",
+ "X-RapidAPI-Host": "zip.leeworks.dev",
+ },
+)
+data = resp.json()
+print(f"{data['city']}, {data['state']}")
+# → "Chicago, IL"
+```
+
+## Pricing
+
+| Plan | Requests/mo | Price | Best for |
+|------|------------|-------|---------|
+| Free | 500 | $0 | Prototyping |
+| Basic | 10,000 | $9/mo | Small apps |
+| Pro | 100,000 | $19/mo | Growing products |
+| Ultra | 1,000,000 | $49/mo | High volume |
+
+**[Subscribe on RapidAPI →](https://rapidapi.com/leeworks/api/zip-enrichment)**
+
+## Use Case: Auto-fill City/State on Checkout
+
+Here's a complete React component that auto-fills city and state when a user enters their ZIP:
+
+```tsx
+import { useState } from 'react';
+
+export function ZipField() {
+ const [zip, setZip] = useState('');
+ const [location, setLocation] = useState<{ city: string; state: string } | null>(null);
+
+ const handleZipChange = async (e: React.ChangeEvent) => {
+ const value = e.target.value.replace(/\D/g, '').slice(0, 5);
+ setZip(value);
+ if (value.length === 5) {
+ const res = await fetch(`/api/zip-lookup?zip=${value}`);
+ if (res.ok) setLocation(await res.json());
+ }
+ };
+
+ return (
+
+
+ {location &&
📍 {location.city}, {location.state}
}
+
+ );
+}
+```
+
+## Conclusion
+
+The leeworks.dev **ZIP code enrichment API** is the fastest way to add location intelligence to any application. With a simple GET request, you get city, state, county, timezone, and coordinates — no geocoding, no rate-limit headaches.
+
+**[Get started for free →](https://rapidapi.com/leeworks/api/zip-enrichment)**
+
+
+
diff --git a/docs-site/src/pages/holidays.astro b/docs-site/src/pages/holidays.astro
new file mode 100644
index 0000000..9c949b6
--- /dev/null
+++ b/docs-site/src/pages/holidays.astro
@@ -0,0 +1,31 @@
+---
+import Base from '../layouts/Base.astro';
+
+const apiName = 'holidays';
+const titles: Record = {
+ 'zip-enrichment': 'ZIP Enrichment API',
+ 'holidays': 'Holidays API',
+ 'air-quality': 'Air Quality API',
+};
+const title = titles[apiName];
+---
+
+
+
+
+
diff --git a/docs-site/src/pages/index.astro b/docs-site/src/pages/index.astro
new file mode 100644
index 0000000..783b1f8
--- /dev/null
+++ b/docs-site/src/pages/index.astro
@@ -0,0 +1,56 @@
+---
+import Base from '../layouts/Base.astro';
+---
+
+
+
+
+
Simple. Reliable. APIs.
+
Production-ready data APIs for ZIP enrichment, public holidays, and air quality. Available on RapidAPI.
+
Get API Key on RapidAPI
+
+
+
+
+
In Development
+
ZIP Enrichment API
+
Enrich US ZIP codes with city, state, county, timezone, lat/long, and population data.
+
+
+
+
In Development
+
Holidays API
+
Public holidays for 100+ countries, filterable by country, year, and type.
+
+
+
+
In Development
+
Air Quality API
+
Real-time and historical AQI data worldwide including PM2.5, PM10, and health recommendations.
+
+
+
+
diff --git a/docs-site/src/pages/zip-enrichment.astro b/docs-site/src/pages/zip-enrichment.astro
new file mode 100644
index 0000000..3f02a9e
--- /dev/null
+++ b/docs-site/src/pages/zip-enrichment.astro
@@ -0,0 +1,31 @@
+---
+import Base from '../layouts/Base.astro';
+
+const apiName = 'zip-enrichment';
+const titles: Record = {
+ 'zip-enrichment': 'ZIP Enrichment API',
+ 'holidays': 'Holidays API',
+ 'air-quality': 'Air Quality API',
+};
+const title = titles[apiName];
+---
+
+
+
+
+
diff --git a/docs-site/tsconfig.json b/docs-site/tsconfig.json
new file mode 100644
index 0000000..bcbf8b5
--- /dev/null
+++ b/docs-site/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "astro/tsconfigs/strict"
+}
diff --git a/docs/dns.md b/docs/dns.md
new file mode 100644
index 0000000..2bb2d37
--- /dev/null
+++ b/docs/dns.md
@@ -0,0 +1,144 @@
+# DNS Configuration
+
+**Last updated:** 2026-05-24
+**Status:** Planned (Phase 6 pre-launch)
+
+---
+
+## DNS Provider
+
+DNS for `leeworks.dev` is managed externally (by the human operator via their registrar/DNS provider). The agent cannot directly create DNS records. This document tracks the required records for human operator action.
+
+---
+
+## Required Records
+
+All records should point to the cluster ingress IP. To find the current ingress IP:
+
+```bash
+kubectl get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
+```
+
+| Subdomain | Type | Target | Purpose | TLS Required |
+|-----------|------|--------|---------|-------------|
+| `zip.leeworks.dev` | A | `` | ZIP Enrichment API | Yes (cert-manager) |
+| `holidays.leeworks.dev` | A | `` | Holidays API | Yes (cert-manager) |
+| `aqi.leeworks.dev` | A | `` | Air Quality API | Yes (cert-manager) |
+| `docs.leeworks.dev` | A | `` | Documentation site | Yes (cert-manager) |
+| `status.leeworks.dev` | A | `` | Gatus status page | Yes (cert-manager) |
+| `registry.leeworks.dev` | A | `` | Container registry (Gitea) | Yes (cert-manager) |
+| `grafana.leeworks.dev` | A | `` | Grafana (internal/restricted) | Yes (cert-manager) |
+
+---
+
+## TLS Certificate Management
+
+TLS certificates are issued automatically by **cert-manager** using Let's Encrypt (ACME HTTP-01 or DNS-01 challenge).
+
+### Prerequisites
+- cert-manager deployed in the cluster (part of Talos setup)
+- A `ClusterIssuer` configured for Let's Encrypt
+
+### ClusterIssuer (Let's Encrypt Production)
+
+```yaml
+apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+ name: letsencrypt-prod
+spec:
+ acme:
+ server: https://acme-v02.api.letsencrypt.org/directory
+ email: legal@leeworks.dev
+ privateKeySecretRef:
+ name: letsencrypt-prod-key
+ solvers:
+ - http01:
+ ingress:
+ class: nginx
+```
+
+### Example Ingress with TLS
+
+```yaml
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: zip-enrichment-ingress
+ namespace: zip-enrichment
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+ nginx.ingress.kubernetes.io/ssl-redirect: "true"
+spec:
+ ingressClassName: nginx
+ tls:
+ - hosts:
+ - zip.leeworks.dev
+ secretName: zip-tls
+ rules:
+ - host: zip.leeworks.dev
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: zip-enrichment
+ port:
+ number: 3000
+```
+
+---
+
+## Verification Steps
+
+After DNS records are created:
+
+```bash
+# Check DNS resolution
+dig zip.leeworks.dev +short
+dig holidays.leeworks.dev +short
+dig aqi.leeworks.dev +short
+dig docs.leeworks.dev +short
+dig status.leeworks.dev +short
+dig registry.leeworks.dev +short
+
+# Check TLS certificates (once services are deployed)
+curl -v https://zip.leeworks.dev/health 2>&1 | grep -E "SSL|certificate|issuer"
+
+# Check cert-manager issued certs
+kubectl get certificates -A
+
+# Expect HTTP 200 on health endpoints
+for host in zip.leeworks.dev holidays.leeworks.dev aqi.leeworks.dev; do
+ echo -n "$host: "
+ curl -s -o /dev/null -w "%{http_code}" https://$host/health
+ echo
+done
+```
+
+---
+
+## Action Required (Human Operator)
+
+The following actions require human operator access to the DNS provider:
+
+1. Log into the DNS provider managing `leeworks.dev`
+2. Find the cluster ingress IP: `kubectl get svc -n ingress-nginx ingress-nginx-controller`
+3. Create/update the 6 A records listed in the table above
+4. Verify propagation: `dig +trace zip.leeworks.dev`
+
+DNS propagation typically takes 5–60 minutes.
+
+---
+
+## Current Status
+
+- [ ] Cluster ingress IP confirmed
+- [ ] `zip.leeworks.dev` → DNS record created
+- [ ] `holidays.leeworks.dev` → DNS record created
+- [ ] `aqi.leeworks.dev` → DNS record created
+- [ ] `docs.leeworks.dev` → DNS record created
+- [ ] `status.leeworks.dev` → DNS record created
+- [ ] `registry.leeworks.dev` → DNS record created
+- [ ] TLS certificates issued and valid for all 6 subdomains
diff --git a/docs/legal/acceptable-use-policy.md b/docs/legal/acceptable-use-policy.md
new file mode 100644
index 0000000..c58e64a
--- /dev/null
+++ b/docs/legal/acceptable-use-policy.md
@@ -0,0 +1,93 @@
+# Acceptable Use Policy
+
+**Effective Date:** 2026-05-24
+**Contact:** legal@leeworks.dev
+
+---
+
+## 1. Purpose
+
+This Acceptable Use Policy ("AUP") defines the rules for using leeworks.dev APIs. It applies to all users regardless of plan. Violations may result in immediate account suspension.
+
+## 2. Rate Limits and Abuse
+
+### 2.1 Respect Your Plan Limits
+
+Each subscription plan includes defined rate limits:
+
+| Plan | Requests/min | Requests/month |
+|------|-------------|---------------|
+| Free | 10 | 500 |
+| Basic | 60 | 10,000 |
+| Pro | 300 | 100,000 |
+| Ultra | 1,000 | 1,000,000 |
+
+You must not exceed your plan's limits through any means.
+
+### 2.2 Prohibited Rate Limit Circumvention
+
+The following are explicitly prohibited:
+- Using multiple API keys or accounts to aggregate quota
+- Caching responses for redistribution beyond your own application
+- Rotating IP addresses to avoid throttling
+- Using proxies or VPNs specifically to bypass rate limits
+
+## 3. Prohibited Uses
+
+### 3.1 Data Scraping and Bulk Download
+
+You may **not**:
+- Download or cache the entire dataset backing any API
+- Make sequential requests designed to reconstruct the underlying database
+- Use automated tools to systematically extract all available data points
+
+### 3.2 Resale and Redistribution
+
+You may **not**:
+- Resell, sublicense, or redistribute API access to third parties
+- Build a competing API product that serves our data to others
+- Offer a "proxy" service that wraps our API for other developers
+
+### 3.3 Malicious and Illegal Use
+
+You may **not**:
+- Use the APIs for any illegal purpose under applicable law
+- Use the APIs to harass, stalk, or harm any individual
+- Attempt to compromise the security or integrity of our systems
+- Reverse-engineer our APIs beyond what's documented in the OpenAPI spec
+- Use the APIs to generate or distribute spam
+
+### 3.4 Infrastructure Attacks
+
+You may **not**:
+- Perform denial-of-service attacks against our infrastructure
+- Probe our systems for vulnerabilities without prior written authorization
+- Exploit bugs or errors to gain elevated access
+
+## 4. Acceptable Uses
+
+The following are examples of acceptable use:
+- Integrating ZIP code, holiday, or air quality data into your own product
+- Building dashboards, mobile apps, or internal tools
+- Academic research (within Free plan limits)
+- Automated data fetching within your plan's rate limits
+
+## 5. Monitoring and Enforcement
+
+We continuously monitor API usage for abuse. Automated systems may flag suspicious patterns. Flagged accounts may be:
+- Throttled further without notice
+- Required to verify identity
+- Temporarily suspended pending review
+- Permanently terminated for serious violations
+
+## 6. Reporting Abuse
+
+If you observe misuse of our APIs (e.g., someone redistributing your API key), please report it to **legal@leeworks.dev** immediately.
+
+## 7. Changes
+
+We may update this AUP at any time. Significant changes will be announced with an updated effective date. Continued use constitutes acceptance.
+
+## 8. Contact
+
+Questions about this policy: **legal@leeworks.dev**
diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md
new file mode 100644
index 0000000..3c4ec6b
--- /dev/null
+++ b/docs/legal/privacy-policy.md
@@ -0,0 +1,96 @@
+# Privacy Policy
+
+**Effective Date:** 2026-05-24
+**Contact:** legal@leeworks.dev
+
+---
+
+## 1. Overview
+
+leeworks.dev ("we", "us") operates the ZIP Enrichment, Holidays, and Air Quality APIs. This Privacy Policy describes what data we collect when you use our Services, how we use it, and your rights regarding that data.
+
+## 2. What Data We Collect
+
+### 2.1 Request Logs
+
+When you make API calls, we log:
+- API key identifier (hashed/truncated — not the full key)
+- IP address of the requesting client
+- HTTP method and endpoint path
+- Response status code
+- Request timestamp
+- Response time (latency)
+
+**We do not log the full content of request or response bodies unless required for debugging.**
+
+### 2.2 Account Data (via RapidAPI)
+
+If you subscribe through RapidAPI, your account data (name, email, billing information) is managed by RapidAPI, not by us. Please review [RapidAPI's Privacy Policy](https://rapidapi.com/privacy/).
+
+### 2.3 Cookies and Tracking
+
+The API endpoints themselves do not use cookies. Our documentation site (`docs.leeworks.dev`) may use minimal session cookies for navigation only — no analytics or tracking cookies.
+
+## 3. How We Use Your Data
+
+We use collected data to:
+- Monitor API health and uptime
+- Detect and prevent abuse (rate limit evasion, scraping)
+- Debug issues and improve service reliability
+- Generate aggregate usage statistics (anonymized)
+- Respond to support requests
+
+**We do not sell your personal data to third parties. Ever.**
+
+## 4. Data Retention
+
+| Data Type | Retention Period |
+|-----------|-----------------|
+| Request logs (IP + endpoint) | 90 days |
+| Aggregated usage metrics | 12 months |
+| Billing records (via RapidAPI) | Per RapidAPI policy |
+
+After the retention period, logs are automatically deleted.
+
+## 5. Data Sharing
+
+We share data only in the following circumstances:
+- **With RapidAPI**: billing and subscription management
+- **Legal requirements**: if required by law, court order, or government request
+- **Service providers**: hosting infrastructure providers (under data processing agreements)
+
+We do not share raw request logs with any third parties.
+
+## 6. Security
+
+We take reasonable technical and organizational measures to protect your data:
+- API keys are transmitted over HTTPS only
+- Access to log storage is restricted to authorized personnel
+- Our cluster uses Kubernetes RBAC and network policies
+
+However, no system is 100% secure. If you discover a security vulnerability, please report it to legal@leeworks.dev.
+
+## 7. Your Rights
+
+Depending on your jurisdiction, you may have rights to:
+- Access the personal data we hold about you
+- Request deletion of your data
+- Object to or restrict processing
+
+To exercise these rights, contact us at legal@leeworks.dev. We will respond within 30 days.
+
+## 8. Children's Privacy
+
+Our Services are not directed at children under 13. We do not knowingly collect data from children. If you believe a child has submitted data, contact us and we will delete it promptly.
+
+## 9. International Transfers
+
+Our services are hosted in the United States. By using the Services, you consent to the transfer and processing of your data in the US.
+
+## 10. Changes to This Policy
+
+We may update this Privacy Policy periodically. We will notify users of material changes by updating the effective date above and posting a notice. Continued use of the Services after changes constitutes acceptance.
+
+## 11. Contact
+
+For privacy inquiries: **legal@leeworks.dev**
diff --git a/docs/legal/terms-of-service.md b/docs/legal/terms-of-service.md
new file mode 100644
index 0000000..64d0587
--- /dev/null
+++ b/docs/legal/terms-of-service.md
@@ -0,0 +1,93 @@
+# Terms of Service
+
+**Effective Date:** 2026-05-24
+**Contact:** legal@leeworks.dev
+
+---
+
+## 1. Acceptance of Terms
+
+By accessing or using any API offered by leeworks.dev ("Services"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Services.
+
+## 2. Description of Services
+
+leeworks.dev provides data API services including:
+- ZIP Enrichment API (`zip.leeworks.dev`)
+- Holidays API (`holidays.leeworks.dev`)
+- Air Quality API (`aqi.leeworks.dev`)
+
+These APIs are offered via RapidAPI and directly. Access requires a valid API key.
+
+## 3. API Usage Limits
+
+- Each plan has defined rate limits (requests per minute and per month). Exceeding your plan's limits will result in HTTP 429 responses.
+- You must not circumvent rate limiting through multiple accounts, shared keys, or other technical means.
+- Free and Basic plan users are limited to non-commercial use unless explicitly stated otherwise.
+
+## 4. Prohibited Use
+
+You may not use the Services to:
+- Resell or redistribute the API data or API access without written permission
+- Scrape, download, or replicate the underlying dataset in bulk
+- Build a competing API product using our data
+- Violate any applicable laws, including data privacy regulations
+- Harass, harm, or interfere with other users or our infrastructure
+
+See also the [Acceptable Use Policy](./acceptable-use-policy.md).
+
+## 5. Account Registration and Security
+
+- You are responsible for keeping your API key confidential.
+- You are responsible for all activity under your API key.
+- Notify us immediately at legal@leeworks.dev if you suspect unauthorized use.
+
+## 6. Payment and Billing
+
+- Paid plans are billed through RapidAPI according to their billing terms.
+- Refunds are handled at our discretion on a case-by-case basis. Contact legal@leeworks.dev within 7 days of a charge.
+- We reserve the right to change pricing with 30 days' notice.
+
+## 7. Data Accuracy Disclaimer
+
+The data provided by leeworks.dev APIs is sourced from public datasets. We make no warranty as to the accuracy, completeness, or fitness for any particular purpose. You use the data at your own risk.
+
+## 8. Service Availability
+
+- We target 99.9% uptime but make no formal SLA guarantee on free or Basic plans.
+- We reserve the right to take the service down for maintenance with or without notice.
+- See `status.leeworks.dev` for real-time uptime information.
+
+## 9. Intellectual Property
+
+- The APIs, documentation, and underlying software are the intellectual property of leeworks.dev.
+- Response data may be used in your own products subject to these Terms.
+- You may not claim ownership of the data or present it as proprietary to you.
+
+## 10. Termination
+
+We may suspend or terminate your access to the Services immediately, without prior notice, for:
+- Violation of these Terms
+- Suspected abuse or fraud
+- Non-payment of applicable fees
+
+Upon termination, your license to use the Services ceases immediately.
+
+## 11. Limitation of Liability
+
+TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, LEEWORKS.DEV SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING LOSS OF PROFITS, DATA, OR BUSINESS, ARISING OUT OF OR IN CONNECTION WITH YOUR USE OF THE SERVICES.
+
+## 12. Indemnification
+
+You agree to indemnify and hold harmless leeworks.dev from any claims, damages, or expenses (including legal fees) arising from your use of the Services or violation of these Terms.
+
+## 13. Changes to Terms
+
+We may modify these Terms at any time. We will post changes on this page with an updated effective date. Continued use of the Services after changes constitutes acceptance.
+
+## 14. Governing Law
+
+These Terms are governed by the laws of the United States. Any disputes shall be resolved in the courts of appropriate jurisdiction.
+
+## 15. Contact
+
+Questions about these Terms? Contact us at: **legal@leeworks.dev**
diff --git a/docs/metrics-standard.md b/docs/metrics-standard.md
new file mode 100644
index 0000000..bc68245
--- /dev/null
+++ b/docs/metrics-standard.md
@@ -0,0 +1,309 @@
+# API Metrics Instrumentation Standard
+
+**Version:** 1.0
+**Date:** 2026-05-24
+**Applies to:** All leeworks.dev API services (zip-enrichment, holidays, air-quality)
+
+---
+
+## Overview
+
+Every API service MUST expose Prometheus-compatible metrics at `GET /metrics`. This document defines the required metrics, label conventions, and provides reference middleware implementations for both Fastify (Node.js) and FastAPI (Python).
+
+---
+
+## Required Metrics
+
+### 1. `api_requests_total`
+
+| Field | Value |
+|-------|-------|
+| **Type** | Counter |
+| **Description** | Total number of HTTP requests received |
+| **Labels** | `api`, `route`, `method`, `status` |
+
+**Label values:**
+- `api`: one of `zip-enrichment`, `holidays`, `air-quality`
+- `route`: the matched route pattern, e.g. `/v1/lookup`, `/v1/holidays`
+- `method`: HTTP method, e.g. `GET`, `POST`
+- `status`: HTTP status code as string, e.g. `200`, `404`, `429`, `403`
+
+**Example:**
+```
+api_requests_total{api="zip-enrichment",route="/v1/lookup",method="GET",status="200"} 1234
+api_requests_total{api="zip-enrichment",route="/v1/lookup",method="GET",status="429"} 12
+api_requests_total{api="zip-enrichment",route="/v1/lookup",method="GET",status="403"} 3
+```
+
+---
+
+### 2. `api_response_duration_seconds`
+
+| Field | Value |
+|-------|-------|
+| **Type** | Histogram |
+| **Description** | HTTP response latency in seconds |
+| **Labels** | `api`, `route` |
+| **Buckets** | `0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5` |
+
+**Example:**
+```
+api_response_duration_seconds_bucket{api="holidays",route="/v1/holidays",le="0.05"} 800
+api_response_duration_seconds_bucket{api="holidays",route="/v1/holidays",le="0.1"} 990
+api_response_duration_seconds_sum{api="holidays",route="/v1/holidays"} 45.2
+api_response_duration_seconds_count{api="holidays",route="/v1/holidays"} 1000
+```
+
+---
+
+### 3. `api_data_freshness_seconds`
+
+| Field | Value |
+|-------|-------|
+| **Type** | Gauge |
+| **Description** | Seconds since the local dataset was last seeded/refreshed |
+| **Labels** | `api`, `dataset` |
+| **Unit** | Seconds (Unix timestamp diff: `now - last_seed_time`) |
+
+**Label values:**
+- `dataset`: a descriptive name for the dataset, e.g. `zip_codes`, `us_holidays`, `aqi_readings`
+
+**Example:**
+```
+api_data_freshness_seconds{api="air-quality",dataset="aqi_readings"} 86400
+api_data_freshness_seconds{api="zip-enrichment",dataset="zip_codes"} 2592000
+```
+
+A value of `0` means freshly seeded; values growing toward `2592000` (30 days) are expected for monthly re-seed schedules.
+
+---
+
+## Reference Implementations
+
+### Fastify (Node.js/TypeScript)
+
+Install dependencies:
+```bash
+npm install prom-client
+```
+
+**`src/metrics.ts`:**
+```typescript
+import { Registry, Counter, Histogram, Gauge } from 'prom-client';
+
+export const register = new Registry();
+
+export const requestsTotal = new Counter({
+ name: 'api_requests_total',
+ help: 'Total number of HTTP requests received',
+ labelNames: ['api', 'route', 'method', 'status'],
+ registers: [register],
+});
+
+export const responseDuration = new Histogram({
+ name: 'api_response_duration_seconds',
+ help: 'HTTP response latency in seconds',
+ labelNames: ['api', 'route'],
+ buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
+ registers: [register],
+});
+
+export const dataFreshness = new Gauge({
+ name: 'api_data_freshness_seconds',
+ help: 'Seconds since the local dataset was last seeded',
+ labelNames: ['api', 'dataset'],
+ registers: [register],
+});
+```
+
+**`src/metricsMiddleware.ts`:**
+```typescript
+import { FastifyPluginAsync } from 'fastify';
+import { register, requestsTotal, responseDuration } from './metrics';
+
+const API_NAME = process.env.API_NAME ?? 'unknown'; // set per service
+
+export const metricsPlugin: FastifyPluginAsync = async (fastify) => {
+ // Expose /metrics endpoint
+ fastify.get('/metrics', async (_req, reply) => {
+ reply.header('Content-Type', register.contentType);
+ return register.metrics();
+ });
+
+ // Instrument all routes
+ fastify.addHook('onRequest', async (request, _reply) => {
+ (request as any)._startTime = process.hrtime.bigint();
+ });
+
+ fastify.addHook('onResponse', async (request, reply) => {
+ const startTime = (request as any)._startTime as bigint;
+ const durationMs = Number(process.hrtime.bigint() - startTime) / 1e6;
+ const route = request.routerPath ?? request.url;
+
+ requestsTotal.labels(API_NAME, route, request.method, String(reply.statusCode)).inc();
+ responseDuration.labels(API_NAME, route).observe(durationMs / 1000);
+ });
+};
+```
+
+**Register in main:**
+```typescript
+import { metricsPlugin } from './metricsMiddleware';
+await fastify.register(metricsPlugin);
+```
+
+**Update data freshness gauge (call after each seed):**
+```typescript
+import { dataFreshness } from './metrics';
+// Call this after each DB seed completes:
+dataFreshness.labels('zip-enrichment', 'zip_codes').set(0);
+// Or set it to seconds since last seed on startup:
+dataFreshness.labels('zip-enrichment', 'zip_codes').set(secondsSinceLastSeed);
+```
+
+---
+
+### FastAPI (Python)
+
+Install dependencies:
+```bash
+pip install prometheus-client starlette
+```
+
+**`metrics.py`:**
+```python
+from prometheus_client import Counter, Histogram, Gauge, REGISTRY, CollectorRegistry
+
+registry = CollectorRegistry()
+
+requests_total = Counter(
+ 'api_requests_total',
+ 'Total number of HTTP requests received',
+ ['api', 'route', 'method', 'status'],
+ registry=registry,
+)
+
+response_duration = Histogram(
+ 'api_response_duration_seconds',
+ 'HTTP response latency in seconds',
+ ['api', 'route'],
+ buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
+ registry=registry,
+)
+
+data_freshness = Gauge(
+ 'api_data_freshness_seconds',
+ 'Seconds since the local dataset was last seeded',
+ ['api', 'dataset'],
+ registry=registry,
+)
+```
+
+**`metrics_middleware.py`:**
+```python
+import time
+import os
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import Response
+from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
+from metrics import requests_total, response_duration, registry
+
+API_NAME = os.getenv("API_NAME", "unknown")
+
+
+class MetricsMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ start = time.time()
+ response = await call_next(request)
+ duration = time.time() - start
+
+ route = request.url.path
+ requests_total.labels(
+ api=API_NAME,
+ route=route,
+ method=request.method,
+ status=str(response.status_code),
+ ).inc()
+ response_duration.labels(api=API_NAME, route=route).observe(duration)
+
+ return response
+
+
+async def metrics_endpoint(request: Request):
+ return Response(
+ generate_latest(registry),
+ media_type=CONTENT_TYPE_LATEST,
+ )
+```
+
+**Register in FastAPI app:**
+```python
+from fastapi import FastAPI
+from starlette.routing import Route
+from metrics_middleware import MetricsMiddleware, metrics_endpoint
+
+app = FastAPI()
+app.add_middleware(MetricsMiddleware)
+app.add_route("/metrics", metrics_endpoint)
+```
+
+---
+
+## Prometheus Scrape Configuration
+
+Add to your Prometheus `scrape_configs` (or ServiceMonitor for kube-prometheus-stack):
+
+```yaml
+# prometheus-additional-scrapes.yaml
+- job_name: 'leeworks-apis'
+ kubernetes_sd_configs:
+ - role: pod
+ relabel_configs:
+ - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
+ action: keep
+ regex: "true"
+ - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
+ action: replace
+ target_label: __metrics_path__
+ regex: (.+)
+ - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
+ action: replace
+ regex: ([^:]+)(?::\d+)?;(\d+)
+ replacement: $1:$2
+ target_label: __address__
+```
+
+Add annotations to each API pod:
+```yaml
+annotations:
+ prometheus.io/scrape: "true"
+ prometheus.io/port: "3000" # or 8000 for FastAPI
+ prometheus.io/path: "/metrics"
+```
+
+---
+
+## Grafana Dashboard
+
+A reference dashboard JSON is available at `docs/grafana-api-dashboard.json` (TBD — will be committed once Grafana is deployed per issue #7).
+
+Key panels to include:
+1. Request rate by API and status (`rate(api_requests_total[5m])`)
+2. P50/P95/P99 latency (`histogram_quantile(0.99, rate(api_response_duration_seconds_bucket[5m]))`)
+3. Error rate = non-2xx / total requests
+4. Data freshness gauge per API
+5. Request volume heatmap
+
+---
+
+## Compliance Checklist
+
+Before marking an API server PR as ready:
+
+- [ ] `GET /metrics` returns `text/plain; version=0.0.4; charset=utf-8`
+- [ ] `api_requests_total` increments on every request with correct labels
+- [ ] `api_response_duration_seconds` has observations on every request
+- [ ] `api_data_freshness_seconds` is set on startup and after each seed
+- [ ] Pod annotations for Prometheus scraping are present in the Helm chart values
+- [ ] `API_NAME` env var is set correctly per deployment
diff --git a/docs/registry.md b/docs/registry.md
new file mode 100644
index 0000000..e99a176
--- /dev/null
+++ b/docs/registry.md
@@ -0,0 +1,163 @@
+# Container Registry: registry.leeworks.dev
+
+**Decision Date:** 2026-05-24
+**Status:** Planned (Phase 0 prerequisite)
+
+---
+
+## Decision: Use Gitea's Built-in Container Registry
+
+We will use **Gitea's built-in container registry** (OCI-compatible, enabled via `GITEA_CONTAINER_REGISTRY`) rather than deploying a separate `distribution/distribution` instance.
+
+### Rationale
+
+1. **No new infra** — Gitea is already deployed; enabling the container registry is a config flag, not a new deployment.
+2. **Integrated auth** — API keys, org-scoped tokens, and CI secrets work natively with the Gitea registry.
+3. **Simpler CI** — Gitea Actions workflows can use `${{ secrets.GITEA_TOKEN }}` to push to `gitea.leeworks.dev/leeworks-agents/`.
+4. **OCI compliance** — Gitea's container registry is OCI v1 compliant, compatible with Docker, Podman, and Kubernetes image pulls.
+
+---
+
+## Registry Hostname
+
+```
+registry.leeworks.dev
+```
+
+This will be a reverse proxy/ingress alias for `gitea.leeworks.dev` (Gitea's container registry endpoint).
+
+Alternatively, Docker clients can use the Gitea hostname directly:
+```
+gitea.leeworks.dev/leeworks-agents/:
+```
+
+If a separate hostname is preferred by the operator, configure an Nginx ingress to proxy `registry.leeworks.dev` → Gitea's container registry port.
+
+---
+
+## Image Naming Convention
+
+```
+registry.leeworks.dev/leeworks-agents/:
+```
+
+| API | Image |
+|-----|-------|
+| ZIP Enrichment | `registry.leeworks.dev/leeworks-agents/zip-enrichment:latest` |
+| Holidays | `registry.leeworks.dev/leeworks-agents/holidays:latest` |
+| Air Quality | `registry.leeworks.dev/leeworks-agents/air-quality:latest` |
+| Docs Site | `registry.leeworks.dev/leeworks-agents/docs-site:latest` |
+
+Tags should also include the git SHA for traceability: `:` in addition to `:latest`.
+
+---
+
+## Authentication
+
+### Pushing from CI (Gitea Actions)
+
+```yaml
+- name: Log in to registry
+ run: |
+ echo "${{ secrets.GITEA_TOKEN }}" | docker login registry.leeworks.dev \
+ -u ${{ gitea.actor }} --password-stdin
+
+- name: Build and push
+ run: |
+ docker build -t registry.leeworks.dev/leeworks-agents/${{ gitea.repository_name }}:${{ gitea.sha }} .
+ docker push registry.leeworks.dev/leeworks-agents/${{ gitea.repository_name }}:${{ gitea.sha }}
+ docker tag registry.leeworks.dev/leeworks-agents/${{ gitea.repository_name }}:${{ gitea.sha }} \
+ registry.leeworks.dev/leeworks-agents/${{ gitea.repository_name }}:latest
+ docker push registry.leeworks.dev/leeworks-agents/${{ gitea.repository_name }}:latest
+```
+
+### Pulling from Kubernetes
+
+Create an image pull secret in each namespace:
+
+```bash
+kubectl create secret docker-registry gitea-registry \
+ --docker-server=registry.leeworks.dev \
+ --docker-username= \
+ --docker-password= \
+ --docker-email=ci@leeworks.dev \
+ -n
+```
+
+Reference in pod spec:
+```yaml
+spec:
+ imagePullSecrets:
+ - name: gitea-registry
+```
+
+---
+
+## Enabling Gitea Container Registry
+
+If not already enabled, the Gitea administrator needs to ensure:
+
+1. In `app.ini` (or Helm values), container registry is enabled:
+ ```ini
+ [packages]
+ ENABLED = true
+ ```
+2. The Gitea service is accessible on port 443 at `gitea.leeworks.dev`.
+3. If using `registry.leeworks.dev` as an alias, configure an Nginx Ingress:
+ ```yaml
+ apiVersion: networking.k8s.io/v1
+ kind: Ingress
+ metadata:
+ name: registry-ingress
+ namespace: gitea
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+ nginx.ingress.kubernetes.io/proxy-body-size: "0"
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
+ spec:
+ ingressClassName: nginx
+ tls:
+ - hosts:
+ - registry.leeworks.dev
+ secretName: registry-tls
+ rules:
+ - host: registry.leeworks.dev
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: gitea-http
+ port:
+ number: 3000
+ ```
+
+---
+
+## Verification
+
+```bash
+# Test login
+docker login registry.leeworks.dev -u -p
+
+# Test push
+docker pull alpine:latest
+docker tag alpine:latest registry.leeworks.dev/leeworks-agents/test:latest
+docker push registry.leeworks.dev/leeworks-agents/test:latest
+
+# Test pull from cluster
+kubectl run test-pull --image=registry.leeworks.dev/leeworks-agents/test:latest \
+ --image-pull-policy=Always --rm -it --restart=Never -- echo "Registry works"
+```
+
+---
+
+## Phase 4 Reference
+
+All API repos should update their `ROADMAP.md §Phase 4` to reference:
+```
+registry.leeworks.dev/leeworks-agents/:
+```
+as the image target for CI pushes and Flux HelmRelease image references.
diff --git a/flux/.gitkeep b/flux/.gitkeep
deleted file mode 100644
index 6aa55af..0000000
--- a/flux/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-# placeholder — populated by Phase-4/5 issues
diff --git a/flux/api-company-source/gitrepository.yaml b/flux/api-company-source/gitrepository.yaml
new file mode 100644
index 0000000..48d1afc
--- /dev/null
+++ b/flux/api-company-source/gitrepository.yaml
@@ -0,0 +1,17 @@
+# This manifest is FOR REFERENCE — the live version must be committed to
+# 0xWheatyz/Talos at testing1/first-cluster/cluster/flux/api-company/
+#
+# See leeworks-agents/api-company#2
+
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: GitRepository
+metadata:
+ name: api-company
+ namespace: flux-system
+spec:
+ interval: 5m
+ url: https://gitea.leeworks.dev/leeworks-agents/api-company
+ ref:
+ branch: main
+ secretRef:
+ name: gitea-leeworks-agents-token # must pre-exist in flux-system ns
diff --git a/flux/api-company-source/kustomization.yaml b/flux/api-company-source/kustomization.yaml
new file mode 100644
index 0000000..b9f9121
--- /dev/null
+++ b/flux/api-company-source/kustomization.yaml
@@ -0,0 +1,19 @@
+# This manifest is FOR REFERENCE — the live version must be committed to
+# 0xWheatyz/Talos at testing1/first-cluster/cluster/flux/api-company/
+#
+# See leeworks-agents/api-company#2
+
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: api-company
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: api-company
+ path: ./flux
+ prune: true
+ wait: true
+ timeout: 5m
diff --git a/flux/docs-site/helmrelease.yaml b/flux/docs-site/helmrelease.yaml
new file mode 100644
index 0000000..25ab03e
--- /dev/null
+++ b/flux/docs-site/helmrelease.yaml
@@ -0,0 +1,89 @@
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: docs-site
+ namespace: docs-site
+spec:
+ interval: 10m
+ chart:
+ spec:
+ chart: raw
+ version: ">=0.2.0"
+ sourceRef:
+ kind: HelmRepository
+ name: bedag
+ namespace: flux-system
+ interval: 60m
+ values:
+ resources:
+ - apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: docs-site
+ namespace: docs-site
+ spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: docs-site
+ template:
+ metadata:
+ labels:
+ app: docs-site
+ spec:
+ imagePullSecrets:
+ - name: gitea-registry
+ containers:
+ - name: docs-site
+ image: registry.leeworks.dev/leeworks-agents/docs-site:latest
+ ports:
+ - containerPort: 80
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ cpu: 200m
+ memory: 128Mi
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 80
+ initialDelaySeconds: 5
+ periodSeconds: 30
+ - apiVersion: v1
+ kind: Service
+ metadata:
+ name: docs-site
+ namespace: docs-site
+ spec:
+ selector:
+ app: docs-site
+ ports:
+ - port: 80
+ targetPort: 80
+ - apiVersion: networking.k8s.io/v1
+ kind: Ingress
+ metadata:
+ name: docs-site
+ namespace: docs-site
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+ nginx.ingress.kubernetes.io/ssl-redirect: "true"
+ spec:
+ ingressClassName: nginx
+ tls:
+ - hosts:
+ - docs.leeworks.dev
+ secretName: docs-site-tls
+ rules:
+ - host: docs.leeworks.dev
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: docs-site
+ port:
+ number: 80
diff --git a/flux/docs-site/helmrepository.yaml b/flux/docs-site/helmrepository.yaml
new file mode 100644
index 0000000..a2e4b78
--- /dev/null
+++ b/flux/docs-site/helmrepository.yaml
@@ -0,0 +1,8 @@
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: HelmRepository
+metadata:
+ name: bedag
+ namespace: flux-system
+spec:
+ interval: 60m
+ url: https://bedag.github.io/helm-charts/
diff --git a/flux/docs-site/kustomization.yaml b/flux/docs-site/kustomization.yaml
new file mode 100644
index 0000000..b4a3d7c
--- /dev/null
+++ b/flux/docs-site/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - namespace.yaml
+ - helmrepository.yaml
+ - helmrelease.yaml
diff --git a/flux/docs-site/namespace.yaml b/flux/docs-site/namespace.yaml
new file mode 100644
index 0000000..79485f9
--- /dev/null
+++ b/flux/docs-site/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: docs-site
diff --git a/flux/gitea-runner/helmrelease.yaml b/flux/gitea-runner/helmrelease.yaml
new file mode 100644
index 0000000..36193a6
--- /dev/null
+++ b/flux/gitea-runner/helmrelease.yaml
@@ -0,0 +1,42 @@
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: gitea-act-runner
+ namespace: gitea-runner
+spec:
+ interval: 10m
+ chart:
+ spec:
+ chart: gitea-act-runner
+ version: ">=0.1.0"
+ sourceRef:
+ kind: HelmRepository
+ name: gitea-charts
+ namespace: flux-system
+ interval: 60m
+ values:
+ replicaCount: 1
+ config:
+ registration:
+ # Gitea instance URL
+ instanceUrl: "https://gitea.leeworks.dev"
+ # Token from Gitea admin → Actions → Runners → New Runner
+ # Store in a Kubernetes Secret named gitea-runner-token
+ tokenFromSecret:
+ secretName: gitea-runner-token
+ secretKey: token
+ runner:
+ # Register at org scope so all leeworks-agents repos can use it
+ labels:
+ - "ubuntu-latest:docker://node:20-bookworm"
+ - "ubuntu-22.04:docker://node:20-bookworm"
+ resources:
+ requests:
+ cpu: 200m
+ memory: 256Mi
+ limits:
+ cpu: 2000m
+ memory: 2Gi
+ # Runner needs Docker socket or dind
+ dind:
+ enabled: true
diff --git a/flux/gitea-runner/helmrepository.yaml b/flux/gitea-runner/helmrepository.yaml
new file mode 100644
index 0000000..68becd3
--- /dev/null
+++ b/flux/gitea-runner/helmrepository.yaml
@@ -0,0 +1,8 @@
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: HelmRepository
+metadata:
+ name: gitea-charts
+ namespace: flux-system
+spec:
+ interval: 60m
+ url: https://dl.gitea.com/charts/
diff --git a/flux/gitea-runner/kustomization.yaml b/flux/gitea-runner/kustomization.yaml
new file mode 100644
index 0000000..b4a3d7c
--- /dev/null
+++ b/flux/gitea-runner/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - namespace.yaml
+ - helmrepository.yaml
+ - helmrelease.yaml
diff --git a/flux/gitea-runner/namespace.yaml b/flux/gitea-runner/namespace.yaml
new file mode 100644
index 0000000..79e8af0
--- /dev/null
+++ b/flux/gitea-runner/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: gitea-runner
diff --git a/flux/kustomization.yaml b/flux/kustomization.yaml
new file mode 100644
index 0000000..42fec49
--- /dev/null
+++ b/flux/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - gitea-runner
+ - monitoring
+ - docs-site
diff --git a/flux/monitoring/gatus-helmrelease.yaml b/flux/monitoring/gatus-helmrelease.yaml
new file mode 100644
index 0000000..5692d05
--- /dev/null
+++ b/flux/monitoring/gatus-helmrelease.yaml
@@ -0,0 +1,94 @@
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: gatus
+ namespace: monitoring
+spec:
+ interval: 15m
+ chart:
+ spec:
+ chart: gatus
+ version: ">=1.0.0"
+ sourceRef:
+ kind: HelmRepository
+ name: minicloudlabs
+ namespace: flux-system
+ interval: 60m
+ values:
+ ingress:
+ enabled: true
+ ingressClassName: nginx
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+ nginx.ingress.kubernetes.io/ssl-redirect: "true"
+ hosts:
+ - host: status.leeworks.dev
+ paths:
+ - path: /
+ pathType: Prefix
+ tls:
+ - secretName: gatus-tls
+ hosts:
+ - status.leeworks.dev
+ config:
+ storage:
+ type: sqlite
+ path: /data/gatus.db
+ endpoints:
+ - name: ZIP Enrichment API
+ url: https://zip.leeworks.dev/health
+ interval: 1m
+ conditions:
+ - "[STATUS] == 200"
+ - "[RESPONSE_TIME] < 1000"
+ alerts:
+ - type: slack
+ description: "ZIP Enrichment API is down"
+ send-on-resolved: true
+
+ - name: Holidays API
+ url: https://holidays.leeworks.dev/health
+ interval: 1m
+ conditions:
+ - "[STATUS] == 200"
+ - "[RESPONSE_TIME] < 1000"
+ alerts:
+ - type: slack
+ description: "Holidays API is down"
+ send-on-resolved: true
+
+ - name: Air Quality API
+ url: https://aqi.leeworks.dev/health
+ interval: 1m
+ conditions:
+ - "[STATUS] == 200"
+ - "[RESPONSE_TIME] < 1000"
+ alerts:
+ - type: slack
+ description: "Air Quality API is down"
+ send-on-resolved: true
+
+ - name: Docs Site
+ url: https://docs.leeworks.dev
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+
+ - name: Container Registry
+ url: https://registry.leeworks.dev/v2/
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+
+ ui:
+ title: "leeworks.dev API Status"
+ description: "Real-time status for all leeworks.dev APIs"
+ logo: ""
+ # Retention: 90 days
+ retention:
+ days: 90
+
+ persistence:
+ enabled: true
+ size: 1Gi
+ mountPath: /data
diff --git a/flux/monitoring/gatus-helmrepository.yaml b/flux/monitoring/gatus-helmrepository.yaml
new file mode 100644
index 0000000..d504877
--- /dev/null
+++ b/flux/monitoring/gatus-helmrepository.yaml
@@ -0,0 +1,8 @@
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: HelmRepository
+metadata:
+ name: minicloudlabs
+ namespace: flux-system
+spec:
+ interval: 60m
+ url: https://minicloudlabs.github.io/helm-charts
diff --git a/flux/monitoring/helmrelease.yaml b/flux/monitoring/helmrelease.yaml
new file mode 100644
index 0000000..8541a9a
--- /dev/null
+++ b/flux/monitoring/helmrelease.yaml
@@ -0,0 +1,92 @@
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: kube-prometheus-stack
+ namespace: monitoring
+spec:
+ interval: 15m
+ chart:
+ spec:
+ chart: kube-prometheus-stack
+ version: ">=58.0.0 <60.0.0"
+ sourceRef:
+ kind: HelmRepository
+ name: prometheus-community
+ namespace: flux-system
+ interval: 60m
+ install:
+ crds: CreateReplace
+ remediation:
+ retries: 3
+ upgrade:
+ crds: CreateReplace
+ remediation:
+ retries: 3
+ values:
+ grafana:
+ enabled: true
+ adminPassword: "${GRAFANA_ADMIN_PASSWORD}" # inject via Secret/substitution
+ ingress:
+ enabled: true
+ ingressClassName: nginx
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+ nginx.ingress.kubernetes.io/ssl-redirect: "true"
+ hosts:
+ - grafana.leeworks.dev
+ tls:
+ - secretName: grafana-tls
+ hosts:
+ - grafana.leeworks.dev
+ persistence:
+ enabled: true
+ size: 5Gi
+ sidecar:
+ dashboards:
+ enabled: true
+ prometheus:
+ prometheusSpec:
+ retention: 30d
+ storageSpec:
+ volumeClaimTemplate:
+ spec:
+ resources:
+ requests:
+ storage: 20Gi
+ # Scrape pods with prometheus.io/scrape=true annotations
+ podMonitorNamespaceSelector: {}
+ podMonitorSelector: {}
+ serviceMonitorNamespaceSelector: {}
+ serviceMonitorSelector: {}
+ # Additional scrape configs for annotation-based discovery
+ additionalScrapeConfigs:
+ - job_name: 'kubernetes-pods'
+ kubernetes_sd_configs:
+ - role: pod
+ relabel_configs:
+ - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
+ action: keep
+ regex: "true"
+ - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
+ action: replace
+ target_label: __metrics_path__
+ regex: (.+)
+ - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
+ action: replace
+ regex: ([^:]+)(?::\d+)?;(\d+)
+ replacement: $1:$2
+ target_label: __address__
+ - action: labelmap
+ regex: __meta_kubernetes_pod_label_(.+)
+ - source_labels: [__meta_kubernetes_namespace]
+ action: replace
+ target_label: kubernetes_namespace
+ - source_labels: [__meta_kubernetes_pod_name]
+ action: replace
+ target_label: kubernetes_pod_name
+ alertmanager:
+ enabled: false # Enable when alert routing is configured
+ kubeStateMetrics:
+ enabled: true
+ nodeExporter:
+ enabled: true
diff --git a/flux/monitoring/helmrepository.yaml b/flux/monitoring/helmrepository.yaml
new file mode 100644
index 0000000..30afa68
--- /dev/null
+++ b/flux/monitoring/helmrepository.yaml
@@ -0,0 +1,8 @@
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: HelmRepository
+metadata:
+ name: prometheus-community
+ namespace: flux-system
+spec:
+ interval: 60m
+ url: https://prometheus-community.github.io/helm-charts
diff --git a/flux/monitoring/kustomization.yaml b/flux/monitoring/kustomization.yaml
new file mode 100644
index 0000000..b5421bf
--- /dev/null
+++ b/flux/monitoring/kustomization.yaml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - namespace.yaml
+ - helmrepository.yaml
+ - helmrelease.yaml
+ - gatus-helmrepository.yaml
+ - gatus-helmrelease.yaml
diff --git a/flux/monitoring/namespace.yaml b/flux/monitoring/namespace.yaml
new file mode 100644
index 0000000..d325236
--- /dev/null
+++ b/flux/monitoring/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: monitoring
diff --git a/monitoring/.gitkeep b/monitoring/.gitkeep
deleted file mode 100644
index 6aa55af..0000000
--- a/monitoring/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-# placeholder — populated by Phase-4/5 issues