replace cadvisor for otelcollector docker_stats plugin, use docker socket proxy for otelcollector

This commit is contained in:
qpismont committed 2026-10-28 20:46:18 +00:00
1 parent 0747dda9e7
commit 887668dde9
7 files changed
+590 -508

No files matched your search

+136 -225
View File
@@ -1,6 +1,6 @@
# Infrastructure qpismont.fr
Infrastructure self-hosted complète hébergée sur serveur dédié, fournissant une plateforme DevOps avec Git, CI/CD, reverse proxy, VPN et monitoring.
Infrastructure self-hosted complète hébergée sur serveur dédié, fournissant une plateforme DevOps avec Git, CI/CD, reverse proxy, VPN et observabilité.
**Domaine :** qpismont.fr
**Stack :** Docker Swarm
@@ -14,35 +14,53 @@ Cette infrastructure héberge l'ensemble des services nécessaires pour le déve
- **Hébergement Git** avec interface web (Gitea)
- **CI/CD automatisé** pour builds et déploiements (Woodpecker)
- **Agent IA de review** de code sur webhooks Gitea (Herald)
- **Reverse proxy** avec HTTPS automatique (Caddy)
- **Accès VPN sécurisé** pour l'administration (WireGuard)
- **Monitoring** des services et ressources (Prometheus + Perses)
- **Backups automatisés** quotidiens vers serveur FTP
- **Accès VPN sécurisé** pour l'administration (WireGuard + CoreDNS)
- **Observabilité** : métriques, traces et dashboards (Prometheus, Tempo, Perses)
- **Alerting** temps réel avec notifications push (Alertmanager + ntfy)
---
## Services déployés
### Production
### Stack `core`
| Service | Version | Description | URL |
|---------|---------|-------------|-----|
| **Gitea** | 1.24.7 | Hébergement Git (alternative GitHub) | https://gitea.qpismont.fr |
| **Woodpecker CI** | 3.11.0 | Plateforme CI/CD (fork de Drone) | https://woodpecker.qpismont.fr |
| **Caddy** | 2-alpine | Reverse proxy + HTTPS automatique | - |
| **WireGuard** | latest | Serveur VPN pour accès distant | UDP:51820 |
| **Prometheus** | 3.7.2 | Collecte de métriques | (interne) |
| **Perses** | latest | Dashboards de monitoring | (via VPN) |
| **cAdvisor** | latest | Métriques des containers | (interne) |
| **Backup** | latest | Sauvegarde quotidienne (4h00) | (interne) |
| Service | Image | Description | Exposition |
|---------|-------|-------------|------------|
| **reverse_caddy** | `caddy:2.11-alpine` | Reverse proxy, HTTPS auto, HTTP/3 | `80`, `443/tcp`, `443/udp` |
| **wireguard_server** | `linuxserver/wireguard:latest` | VPN d'accès distant + CoreDNS | `51820/udp` |
### Stack `git`
| Service | Image | Description | URL |
|---------|-------|-------------|-----|
| **gitea** | `docker.gitea.com/gitea:28.2` | Hébergement Git | https://gitea.qpismont.fr · https://git.qpismont.fr |
| **woodpecker_server** | `woodpeckerci/woodpecker-server:v3.19-alpine` | Serveur CI/CD | https://woodpecker.qpismont.fr |
| **woodpecker_agent** | `woodpeckerci/woodpecker-agent:v3.19-alpine` | Agent d'exécution (Docker socket) | (interne) |
| **herald** | `tintounn/herald:1.2` | Agent IA de review sur webhooks Gitea (Docker socket) | (interne, métriques `:9100`) |
### Stack `observability`
| Service | Image | Description | Exposition |
|---------|-------|-------------|------------|
| **prometheus** | `prom/prometheus:v3.13.3` | TSDB + remote-write receiver | (interne) |
| **otel-collector** | `otel/opentelemetry-collector-contrib:0.164.0` | Collecte métriques + traces | (interne) |
| **docker-proxy-otel** | `tecnativa/docker-socket-proxy:0.4.0` | Proxy socket Docker (lecture seule) | (interne) |
| **tempo** | `grafana/tempo:3.0.3` | Stockage de traces (OTLP) | (interne) |
| **perses** | `persesdev/perses:latest` | Dashboards | (via VPN) |
| **alertmanager** | `prom/alertmanager:v0.34.1` | Routage des alertes | (interne) |
| **blackbox_exporter** | `prom/blackbox-exporter:v0.28.0` | Probes HTTP externes | (interne) |
| **ntfy** | `binwiederhier/ntfy:latest` | Notifications push | https://ntfy.qpismont.fr |
### Configuration
- **Timezone** : Europe/Paris
- **OAuth** : Gitea ↔ Woodpecker intégré
- **Admin** : qpismont
- **Backup retention** : 14 jours
- **Monitoring scrape** : 10 secondes
- **Scrape interval** : 10 secondes
- **Rétention Tempo** : 48 h
- **Rétention Prometheus** : par défaut (15 j), volume persistant
---
@@ -58,57 +76,63 @@ Cette infrastructure héberge l'ensemble des services nécessaires pour le déve
▼ ▼ ▼
[Caddy Proxy] [HTTP/3] [WireGuard VPN]
│ │
┌───────────┼──────────┐ │
│ │ │ │
gitea:3000 woodpecker:8000 │ [Perses]
│ │
www.qpismont.fr [Prometheus]
(désactivé) │
[cAdvisor]
┌───────────┼───────────┬──────────┐ │
│ │ │ │ │
gitea:3000 woodpecker:8000 │ ntfy:80│
│ │
www.qpismont.fr [CoreDNS]
(portfolio) │
[Perses]
[Prometheus]
[Tempo]
┌──────────────────────────────────────────────────────────────┐
│ CI/CD Pipeline │
│ Stack git (CI/CD) │
│ │
│ [Gitea] ←──── OAuth ────→ [Woodpecker Server] │
│ │ │
│ ▼ │
│ [Woodpecker Agent] │
│ │ │
│ ▼ │
│ │ │ │
│ │ webhook ▼ │
│ ▼ [Woodpecker Agent] │
│ [Herald] │ │
│ (review IA) ▼ │
│ [Docker-in-Docker] │
│ (build containers) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Monitoring Stack │
│ Stack observability │
│ │
│ [Prometheus] ←─ scrape ─┬─ prometheus:9090 (self) │
│ │ ├─ reverse_caddy:2019 (metrics) │
│ │ └─ cadvisor:8080 (containers) │
│ │ │
│ └──→ [Perses] (visualisation via VPN) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Backup System │
│ │
│ [Backup Service] ──→ rclone/FTP ──→ [Serveur FTP distant] │
│ │ │ │
│ └─ Volumes Docker: └─ /backups/infra/│
│ • giteadata Format: .tar.gz │
│ • woodpeckerdata (désactivé) Rétention: 14j │
│ • caddy configs (désactivé) Cron: 4h00 │
│ • wireguard (désactivé) │
│ [OTel Collector] ──┬─ hostmetrics (CPU/RAM/disk/net) │
│ │ ├─ docker_stats (métriques containers) │
│ │ ├─ prometheus scrape: │
│ │ │ reverse_caddy:2019 │
│ │ │ herald:9100 │
│ │ │ alertmanager:9093 │
│ │ │ blackbox_exporter:9115 │
│ │ └─ OTLP (4318) │
│ │ │
│ ├── remote_write ──→ [Prometheus] ──→ [Perses] │
│ └── OTLP ──────────→ [Tempo] │
│ │ │
│ [Prometheus] ── alertes ──→ [Alertmanager] ──→ [ntfy] │
└──────────────────────────────────────────────────────────────┘
```
### Stacks Docker Swarm
| Stack | Fichier compose | Services |
|-------|-----------------|----------|
| `core` | `docker-compose.core.yml` | Caddy, WireGuard |
| `git` | `docker-compose.git.yml` | Gitea, Woodpecker (server + agent), Herald |
| `observability` | `docker-compose.observability.yml` | Prometheus, Perses, Tempo, Alertmanager, Blackbox, OTel Collector, proxy socket, ntfy |
### Réseaux Docker Swarm
| Réseau | Type | Services |
|--------|------|----------|
| `reverse` | overlay | Caddy, Gitea, Woodpecker |
| `wireguard_network` | overlay | WireGuard, Perses |
| `prometheus_network` | overlay | Prometheus, Perses, cAdvisor, Caddy, Backup |
| `reverse_network` | overlay | Caddy, Gitea, Woodpecker (server + agent), Herald, ntfy |
| `wireguard_network` | overlay | WireGuard, Prometheus, Perses, Tempo |
| `prometheus_network` | overlay | Caddy, Herald, Prometheus, Perses, Tempo, Alertmanager, Blackbox, OTel Collector, docker-proxy-otel, ntfy |
---
@@ -121,7 +145,7 @@ Cette infrastructure héberge l'ensemble des services nécessaires pour le déve
- **Docker Configs** pour configurations versionnées
### Infrastructure as Code
- Configuration : `docker-compose.yml`
- Configuration : `docker-compose.core.yml` (Caddy, WireGuard), `docker-compose.git.yml` (Gitea, Woodpecker, Herald), `docker-compose.observability.yml` (Prometheus, Perses, Tempo, ntfy…)
- Déploiement : `./deploy.sh` (génère timestamp pour versioning)
- Contexte Docker distant : `prod`
@@ -132,21 +156,21 @@ Cette infrastructure héberge l'ensemble des services nécessaires pour le déve
- **VPN** : WireGuard pour services internes
### Storage
- **Volumes Docker** : giteadata, woodpeckerdata, reversecaddydata, reversecaddyconfig, backuptemp
- **Host mounts** : /home/user/wireguard-config, /var/run/docker.sock (CI/CD)
- **Volumes Docker** : `reversecaddydata`, `reversecaddyconfig` (core) · `giteadata`, `woodpeckerdata` (git) · `prometheusdata`, `tempodata`, `persesdata`, `alertmanagerdata`, `ntfydata` (observability)
- **Host mounts** : `/home/user/wireguard-config`, `/lib/modules` (WireGuard) · `/var/run/docker.sock` (Woodpecker agent, Herald, `docker-proxy-otel`) · `/` en lecture seule (OTel Collector)
### Monitoring
- **Métriques** : Prometheus avec 10s scrape interval
- **Collecte** : OTel Collector (hostmetrics + scrape Prometheus + OTLP)
- **Stockage métriques** : Prometheus (remote-write receiver activé)
- **Stockage traces** : Tempo (OTLP HTTP `:4318`, rétention 48 h)
- **Dashboards** : Perses (accessible via VPN)
- **Container metrics** : cAdvisor
- **Proxy metrics** : Caddy admin API (:2019)
- **Container metrics** : receiver `docker_stats` de l'OTel Collector (labels Swarm `service_name` / `task_name`)
- **Proxy metrics** : Caddy admin API (`:2019`)
- **Probes externes** : blackbox_exporter (`http_2xx` sur les URLs publiques)
- **Alerting** : Prometheus → Alertmanager → ntfy (`http://ntfy:80/alerts`)
### Backups
- **Solution** : docker-volume-backup avec rclone
- **Fréquence** : Quotidien à 4h00
- **Destination** : Serveur FTP distant
- **Rétention** : 14 jours
- **Format** : .tar.gz compressé
- ⚠️ **Aucun service de backup n'est déployé actuellement.** L'ancien service `docker-volume-backup` a été retiré (commit `041eb51`). Les sauvegardes sont à refaire manuellement ou à réintroduire.
---
@@ -166,6 +190,13 @@ Cette infrastructure héberge l'ensemble des services nécessaires pour le déve
- Limite : 1 workflow concurrent, CPU core 1
- Support multi-architectures
### Herald
- Agent IA de review de code, déclenché par webhooks Gitea
- Modèle via OpenRouter (`openai/gpt-5.6-luna-pro`)
- Sandbox d'exécution limitée à 32 itérations
- 5 jobs concurrents max, timeout Gitea 60 s / OpenRouter 600 s
- Métriques Prometheus exposées sur `:9100`
### Caddy
- HTTPS automatique avec Let's Encrypt
- HTTP/3 (QUIC) support
@@ -177,38 +208,44 @@ Cette infrastructure héberge l'ensemble des services nécessaires pour le déve
- VPN moderne et rapide
- Configuration : 1 peer (extensible)
- Accès sécurisé aux services internes
- CoreDNS embarqué pour résoudre `perses.internal`, `prometheus.internal`, `tempo.internal`
- Faible overhead
### Monitoring
- **Prometheus** : Collecte et stockage des métriques
- **Perses** : Visualisation moderne (alternative Grafana)
- **cAdvisor** : Métriques CPU, RAM, I/O des containers
- **OTel Collector** : hostmetrics (CPU, RAM, disque, réseau, load, process) + `docker_stats` (métriques containers) + scrape Prometheus + réception OTLP
- **Prometheus** : stockage des métriques, remote-write receiver
- **Tempo** : stockage des traces OTLP
- **Perses** : visualisation moderne (alternative Grafana)
- **blackbox_exporter** : probes HTTP sur les URLs publiques
- **Alertmanager** : routage des alertes vers ntfy
- Scrape automatique toutes les 10 secondes
### Backups
- Sauvegarde automatique quotidienne
- Support FTP/SFTP via rclone
- Compression gzip
- Notifications optionnelles (Discord, Slack, Email)
- Rétention configurable
### Alerting
- Règles : `ServiceDown` (probe blackbox), `OtelCollectorDown`, `CaddyDown`
- Seuil : 1 minute
- Inhibition : `CaddyDown` supprime les `ServiceDown` (évite le bruit)
- Notification : webhook ntfy avec template `alertmanager`, `send_resolved: true`
---
## URLs et accès
### Services publics
- **Gitea** : https://gitea.qpismont.fr
- **Gitea** : https://gitea.qpismont.fr (alias https://git.qpismont.fr)
- **Woodpecker** : https://woodpecker.qpismont.fr
- **Portfolio** : ~~https://www.qpismont.fr~~ (désactivé)
- **Portfolio** : https://www.qpismont.fr (`qpismont.fr` redirige vers `www`)
- **ntfy** : https://ntfy.qpismont.fr
### Services internes (VPN requis)
- **Perses** : http://perses:8080 (via WireGuard)
- **Prometheus** : http://prometheus:9090 (via WireGuard)
- **Perses** : http://perses.internal:8080 (via WireGuard/CoreDNS)
- **Prometheus** : http://prometheus.internal:9090
- **Tempo** : http://tempo.internal:3200
- **Caddy Admin** : http://reverse_caddy:2019 (métriques)
### VPN
- **Protocole** : WireGuard
- **Port** : 51820/udp
- **Sous-réseau** : `10.13.13.0/24`
- **Config** : `/home/user/wireguard-config/peer1/`
---
@@ -221,12 +258,12 @@ Le déploiement se fait via un contexte Docker distant vers le serveur de produc
./deploy.sh
```
Le script génère un timestamp pour versionner les configs et secrets, puis déploie le stack `core` sur le contexte `prod`.
Le script génère un timestamp pour versionner les configs et secrets, puis déploie les trois stacks (`core`, `git`, `observability`) sur le contexte `prod`.
**Prérequis :**
- Contexte Docker `prod` configuré
- Réseaux overlay créés (`reverse`, `wireguard_network`, `prometheus_network`)
- Secrets configurés dans `./secrets/`
- Réseaux overlay créés (`reverse_network`, `wireguard_network`, `prometheus_network`)
- Secrets configurés dans `./secrets/` (dossier ignoré par Git)
---
@@ -234,156 +271,30 @@ Le script génère un timestamp pour versionner les configs et secrets, puis dé
### Secrets Docker
| Secret | Usage |
|--------|-------|
| `woodpecker_agent_secret` | Communication server/agent Woodpecker |
| `woodpecker_gitea_client` | OAuth client ID Gitea |
| `woodpecker_gitea_secret` | OAuth client secret Gitea |
| `backup_ftp_config` | Configuration rclone pour FTP |
| Secret | Fichier source | Usage |
|--------|----------------|-------|
| `woodpecker_agent_secret` | `./secrets/woodpecker_agent_secret` | Communication server/agent Woodpecker |
| `woodpecker_gitea_client` | `./secrets/woodpecker_gitea_client` | OAuth client ID Gitea |
| `woodpecker_gitea_secret` | `./secrets/woodpecker_gitea_secret` | OAuth client secret Gitea |
| `herald_gitea_token` | `./secrets/herald/herald_gitea_token` | Token API Gitea pour Herald |
| `herald_openrouter_token` | `./secrets/herald/herald_openrouter_token` | Clé API OpenRouter |
| `herald_gitea_header_secret` | `./secrets/herald/herald_gitea_header_secret` | Vérification signature webhook Gitea |
| `herald_sentry_dsn` | `./secrets/herald/herald_sentry_dsn` | DSN Sentry |
### Configs Docker
| Config | Fichier source |
|--------|----------------|
| `caddy_config` | `./Caddyfile` |
| `prometheus_config` | `./prometheus.yml` |
| Config | Fichier source | Stack |
|--------|----------------|-------|
| `caddy_config` | `./Caddyfile` | core |
| `caddy_portfolio_html` | `./portfolio/index.html` | core |
| `wireguard_coredns_config` | `./wireguard/CoreDns` | core |
| `prometheus_config` | `./prometheus/prometheus.yml` | observability |
| `prometheus_rules_config` | `./prometheus/alert.rules.yml` | observability |
| `alertmanager_config` | `./prometheus/alertmanager/alertmanager.yml` | observability |
| `blackbox_config` | `./blackbox/blackbox.yml` | observability |
| `otel_collector_config` | `./otel-collector/config.yaml` | observability |
| `tempo_config` | `./tempo/tempo.yml` | observability |
Les configs et secrets sont versionnés avec `${DATETIME}` pour forcer les mises à jour lors du déploiement.
Les configs et secrets sont versionnés avec `${DATETIME}` pour forcer les mises à jour lors du déploiement. Ils s'accumulent donc à chaque déploiement : voir la section [Nettoyage](#nettoyage).
---
## Problèmes connus
### ⚠️ Service my_cv désactivé
Le Caddyfile contient des routes vers `my_cv:80` mais le service n'existe plus dans docker-compose.yml (retiré dans commit f047317).
**Impact :** Routes `qpismont.fr` et `www.qpismont.fr` ne fonctionnent pas.
### ⚠️ Prometheus non-persistant
Prometheus n'a pas de volume Docker, les métriques sont perdues à chaque redémarrage du container.
**Impact :** Pas d'historique des métriques sur le long terme.
### ⚠️ Volumes backup partiellement désactivés
Seul `giteadata` est actuellement sauvegardé. Les autres volumes (woodpecker, caddy, wireguard) sont commentés dans docker-compose.yml.
**Impact :** Perte de données possible en cas d'incident sur ces volumes.
---
## Documentation
### Fichiers de documentation
- **[README.md](./README.md)** : Ce fichier - présentation générale
- **[ANALYSE.md](./ANALYSE.md)** : Analyse détaillée de l'infrastructure + recommandations d'amélioration
- **[BACKUP.md](./BACKUP.md)** : Documentation complète du système de backup (configuration, utilisation, restauration)
### Documentation externe
- [Docker Swarm](https://docs.docker.com/engine/swarm/)
- [Caddy](https://caddyserver.com/docs/)
- [Gitea](https://docs.gitea.io/)
- [Woodpecker CI](https://woodpecker-ci.org/docs/)
- [WireGuard](https://www.wireguard.com/)
- [Prometheus](https://prometheus.io/docs/)
- [Perses](https://perses.dev/docs/)
---
## Maintenance
### Routine
- **Quotidien** : Backup automatique à 4h00
- **Hebdomadaire** : Vérification logs et backups
- **Mensuel** : Mise à jour des images Docker
- **Trimestriel** : Test de restauration des backups
### Monitoring
```bash
# Status des services
docker --context prod ps
# Logs d'un service
docker --context prod logs -f $(docker --context prod ps -q -f name=core_SERVICE)
# Métriques système
docker --context prod stats
```
### Mises à jour
Modifier les versions dans `docker-compose.yml` puis :
```bash
./deploy.sh
```
Docker Swarm appliquera les changements de manière progressive.
---
## Sécurité
### Mesures en place
✅ HTTPS automatique (Let's Encrypt)
✅ Secrets Docker (credentials chiffrés)
✅ Network isolation (3 réseaux séparés)
✅ VPN pour services internes (WireGuard)
✅ Resource limits sur CI/CD agent
✅ Backups automatisés quotidiens
✅ Non-root user pour Gitea (UID 1000)
### Recommandations
Voir [ANALYSE.md](./ANALYSE.md) pour la liste complète des améliorations de sécurité suggérées.
---
## Historique
### 2025-10-27
- ✅ Upgrade vers Debian 13 (devcontainer)
- ✅ Update Woodpecker v3.11.0
- ✅ Update Gitea v1.24.7
- ✅ Ajout WireGuard VPN
- ✅ Ajout stack monitoring (Prometheus + Perses + cAdvisor)
- ✅ Ajout système de backup automatique
### 2025-06-23
- ✅ Retrait service my_cv (portfolio)
- ✅ Update images Woodpecker et Gitea
### Antérieur
- Déploiement devcontainer Debian
- Upgrades réguliers des services
- Ajout Woodpecker CI
- Migration vers Docker Swarm
---
## Statistiques
- **Services actifs** : 8
- **Réseaux overlay** : 3
- **Volumes Docker** : 5
- **Secrets Docker** : 4
- **Configs Docker** : 2
- **Ports exposés** : 4 (80, 443/tcp, 443/udp, 51820/udp)
- **Backup size** : ~50-500 MB (dépend des données Gitea)
- **Uptime target** : 99%+
---
## Licence et Auteur
**Propriétaire** : qpismont
**Usage** : Personnel
**Repository** : https://gitea.qpismont.fr/qpismont/infra
---
**Dernière mise à jour :** 27 octobre 2025
+1
View File
@@ -1,4 +1,5 @@
#!/bin/bash
DATETIME=$(date +%Y%m%d%H%M%S) docker --context prod stack deploy -c docker-compose.core.yml core --with-registry-auth
DATETIME=$(date +%Y%m%d%H%M%S) docker --context prod stack deploy -c docker-compose.git.yml git --with-registry-auth
DATETIME=$(date +%Y%m%d%H%M%S) docker --context prod stack deploy -c docker-compose.observability.yml observability --with-registry-auth
-100
View File
@@ -19,81 +19,6 @@ services:
- reverse_network
- prometheus_network
woodpecker_server:
image: woodpeckerci/woodpecker-server:v3.19-alpine
volumes:
- woodpeckerdata:/var/lib/woodpecker/
entrypoint: /bin/sh -c "export WOODPECKER_GITEA_CLIENT=$$(cat /run/secrets/woodpecker_gitea_client) && export WOODPECKER_GITEA_SECRET=$$(cat /run/secrets/woodpecker_gitea_secret) && export WOODPECKER_AGENT_SECRET=$$(cat /run/secrets/woodpecker_agent_secret) && /bin/woodpecker-server"
environment:
WOODPECKER_ADMIN: qpismont
WOODPECKER_HOST: https://woodpecker.qpismont.fr
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: https://gitea.qpismont.fr
networks:
- reverse_network
secrets:
- woodpecker_agent_secret
- woodpecker_gitea_client
- woodpecker_gitea_secret
woodpecker_agent:
image: woodpeckerci/woodpecker-agent:v3.19-alpine
volumes:
- /var/run/docker.sock:/var/run/docker.sock
entrypoint: /bin/sh -c "export WOODPECKER_AGENT_SECRET=$$(cat /run/secrets/woodpecker_agent_secret) && /bin/woodpecker-agent"
environment:
WOODPECKER_SERVER: woodpecker_server:9000
WOODPECKER_MAX_WORKFLOWS: 1
WOODPECKER_LIMIT_CPU_SET: 1
networks:
- reverse_network
secrets:
- woodpecker_agent_secret
gitea:
image: docker.gitea.com/gitea:28.2
environment:
- USER_UID=1000
- USER_GID=1000
restart: always
volumes:
- giteadata:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- reverse_network
herald:
image: tintounn/herald:1.2
volumes:
- /var/run/docker.sock:/var/run/docker.sock
entrypoint:
- /bin/sh
- -c
- >-
export GITEA_TOKEN=$$(cat /run/secrets/herald_gitea_token) &&
export OPEN_ROUTER_API_KEY=$$(cat /run/secrets/herald_openrouter_token) &&
export WEBHOOK_SIG_HEADER_SECRET=$$(cat /run/secrets/herald_gitea_header_secret) &&
export SENTRY_DSN=$$(cat /run/secrets/herald_sentry_dsn) &&
/app/herald-server
networks:
- reverse_network
- prometheus_network
secrets:
- herald_gitea_token
- herald_openrouter_token
- herald_gitea_header_secret
- herald_sentry_dsn
environment:
HTTP_PORT: 3000
BOT_MAX_CONCURRENT: 5
GITEA_URL: http://gitea:3000
GITEA_TIMEOUT: 60
OPEN_ROUTER_MODEL: openai/gpt-5.6-luna-pro
OPEN_ROUTER_TIMEOUT: 600
METRICS_BIND_ADDR: 0.0.0.0:9100
SANDBOX_MAX_ITERATIONS: 32
wireguard_server:
image: linuxserver/wireguard
cap_add:
@@ -128,29 +53,6 @@ services:
networks:
- wireguard_network
secrets:
woodpecker_agent_secret:
name: woodpecker_agent_secret_${DATETIME}
file: ./secrets/woodpecker_agent_secret
woodpecker_gitea_secret:
name: woodpecker_gitea_secret_${DATETIME}
file: ./secrets/woodpecker_gitea_secret
woodpecker_gitea_client:
name: woodpecker_gitea_client_${DATETIME}
file: ./secrets/woodpecker_gitea_client
herald_gitea_token:
name: herald_gitea_token_${DATETIME}
file: ./secrets/herald/herald_gitea_token
herald_openrouter_token:
name: herald_openrouter_token_${DATETIME}
file: ./secrets/herald/herald_openrouter_token
herald_gitea_header_secret:
name: herald_gitea_header_secret_${DATETIME}
file: ./secrets/herald/herald_gitea_header_secret
herald_sentry_dsn:
name: herald_sentry_dsn_${DATETIME}
file: ./secrets/herald/herald_sentry_dsn
configs:
caddy_config:
name: caddy_config_${DATETIME}
@@ -163,8 +65,6 @@ configs:
file: ./portfolio/index.html
volumes:
giteadata:
woodpeckerdata:
reversecaddyconfig:
reversecaddydata:
+110
View File
@@ -0,0 +1,110 @@
version: "3"
services:
gitea:
image: docker.gitea.com/gitea:28.2
environment:
- USER_UID=1000
- USER_GID=1000
restart: always
volumes:
- giteadata:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- reverse_network
woodpecker_server:
image: woodpeckerci/woodpecker-server:v3.19-alpine
volumes:
- woodpeckerdata:/var/lib/woodpecker/
entrypoint: /bin/sh -c "export WOODPECKER_GITEA_CLIENT=$$(cat /run/secrets/woodpecker_gitea_client) && export WOODPECKER_GITEA_SECRET=$$(cat /run/secrets/woodpecker_gitea_secret) && export WOODPECKER_AGENT_SECRET=$$(cat /run/secrets/woodpecker_agent_secret) && /bin/woodpecker-server"
environment:
WOODPECKER_ADMIN: qpismont
WOODPECKER_HOST: https://woodpecker.qpismont.fr
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: https://gitea.qpismont.fr
networks:
- reverse_network
secrets:
- woodpecker_agent_secret
- woodpecker_gitea_client
- woodpecker_gitea_secret
woodpecker_agent:
image: woodpeckerci/woodpecker-agent:v3.19-alpine
volumes:
- /var/run/docker.sock:/var/run/docker.sock
entrypoint: /bin/sh -c "export WOODPECKER_AGENT_SECRET=$$(cat /run/secrets/woodpecker_agent_secret) && /bin/woodpecker-agent"
environment:
WOODPECKER_SERVER: woodpecker_server:9000
WOODPECKER_MAX_WORKFLOWS: 1
WOODPECKER_LIMIT_CPU_SET: 1
networks:
- reverse_network
secrets:
- woodpecker_agent_secret
herald:
image: tintounn/herald:1.2
volumes:
- /var/run/docker.sock:/var/run/docker.sock
entrypoint:
- /bin/sh
- -c
- >-
export GITEA_TOKEN=$$(cat /run/secrets/herald_gitea_token) &&
export OPEN_ROUTER_API_KEY=$$(cat /run/secrets/herald_openrouter_token) &&
export WEBHOOK_SIG_HEADER_SECRET=$$(cat /run/secrets/herald_gitea_header_secret) &&
export SENTRY_DSN=$$(cat /run/secrets/herald_sentry_dsn) &&
/app/herald-server
networks:
- reverse_network
- prometheus_network
secrets:
- herald_gitea_token
- herald_openrouter_token
- herald_gitea_header_secret
- herald_sentry_dsn
environment:
HTTP_PORT: 3000
BOT_MAX_CONCURRENT: 5
GITEA_URL: http://gitea:3000
GITEA_TIMEOUT: 60
OPEN_ROUTER_MODEL: openai/gpt-5.6-luna-pro
OPEN_ROUTER_TIMEOUT: 600
METRICS_BIND_ADDR: 0.0.0.0:9100
SANDBOX_MAX_ITERATIONS: 32
secrets:
woodpecker_agent_secret:
name: woodpecker_agent_secret_${DATETIME}
file: ./secrets/woodpecker_agent_secret
woodpecker_gitea_secret:
name: woodpecker_gitea_secret_${DATETIME}
file: ./secrets/woodpecker_gitea_secret
woodpecker_gitea_client:
name: woodpecker_gitea_client_${DATETIME}
file: ./secrets/woodpecker_gitea_client
herald_gitea_token:
name: herald_gitea_token_${DATETIME}
file: ./secrets/herald/herald_gitea_token
herald_openrouter_token:
name: herald_openrouter_token_${DATETIME}
file: ./secrets/herald/herald_openrouter_token
herald_gitea_header_secret:
name: herald_gitea_header_secret_${DATETIME}
file: ./secrets/herald/herald_gitea_header_secret
herald_sentry_dsn:
name: herald_sentry_dsn_${DATETIME}
file: ./secrets/herald/herald_sentry_dsn
volumes:
giteadata:
woodpeckerdata:
networks:
reverse_network:
external: true
prometheus_network:
external: true
+40 -14
View File
@@ -23,7 +23,7 @@ services:
- wireguard_network
blackbox_exporter:
image: prom/blackbox-exporter:v0.28.0
image: prom/blackbox-exporter:v0.29.1
configs:
- source: blackbox_config
target: /etc/blackbox_exporter/config.yml
@@ -52,8 +52,44 @@ services:
- prometheus_network
- reverse_network
docker-proxy-otel:
image: tecnativa/docker-socket-proxy:v0.5.0
environment:
CONTAINERS: 1
INFO: 1
VERSION: 1
PING: 1
# docker_stats écoute /events pour suivre les conteneurs (sinon 403)
EVENTS: 1
POST: 0
EXEC: 0
IMAGES: 0
VOLUMES: 0
NETWORKS: 0
SECRETS: 0
CONFIGS: 0
SWARM: 0
SERVICES: 0
TASKS: 0
NODES: 0
SYSTEM: 0
PLUGINS: 0
SESSION: 0
AUTH: 0
BUILD: 0
COMMIT: 0
DISTRIBUTION: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- prometheus_network
deploy:
resources:
limits:
memory: 64M
otel-collector:
image: otel/opentelemetry-collector-contrib:0.161.0
image: otel/opentelemetry-collector-contrib:0.164.0
configs:
- source: otel_collector_config
target: /etc/otelcol-contrib/config.yaml
@@ -66,12 +102,12 @@ services:
deploy:
resources:
limits:
memory: 512M
memory: 768M
networks:
- prometheus_network
prometheus:
image: prom/prometheus:v3.13.3
image: prom/prometheus:v3.15.1
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
@@ -87,16 +123,6 @@ services:
- prometheus_network
- wireguard_network
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
volumes:
- /:/rootfs:ro
- /var/run:/var/run:rw
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
networks:
- prometheus_network
configs:
prometheus_config:
name: prometheus_config_${DATETIME}
+32 -5
View File
@@ -14,6 +14,35 @@ receivers:
network:
load:
processes:
docker_stats:
endpoint: tcp://docker-proxy-otel:2375
collection_interval: 30s
timeout: 20s
api_version: "1.44"
container_labels_to_metric_labels:
com.docker.swarm.service.name: service_name
com.docker.swarm.task.name: task_name
metrics:
container.cpu.usage.total:
enabled: true
container.cpu.percent:
enabled: true
container.memory.usage.total:
enabled: true
container.memory.percent:
enabled: true
container.memory.usage.limit:
enabled: true
container.network.io.usage.rx_bytes:
enabled: true
container.network.io.usage.tx_bytes:
enabled: true
container.blockio.io_service_bytes_recursive:
enabled: true
container.pids.count:
enabled: true
container.uptime:
enabled: true
prometheus:
config:
scrape_configs:
@@ -21,10 +50,6 @@ receivers:
scrape_interval: 10s
static_configs:
- targets: ['reverse_caddy:2019']
- job_name: cadvisor
scrape_interval: 10s
static_configs:
- targets: ['cadvisor:8080']
- job_name: herald
scrape_interval: 10s
static_configs:
@@ -64,6 +89,8 @@ processors:
exporters:
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
resource_to_telemetry_conversion:
enabled: true
otlphttp/tempo:
endpoint: http://tempo:4318
@@ -78,7 +105,7 @@ service:
port: 8888
pipelines:
metrics:
receivers: [otlp, prometheus, hostmetrics]
receivers: [otlp, prometheus, hostmetrics, docker_stats]
processors: [memory_limiter, batch]
exporters: [prometheusremotewrite]
traces:
+271 -164
View File
@@ -5,38 +5,63 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Portfolio of Quentin Pismont, senior/lead platform engineer: distributed systems, observability, security and self-hosted CI/CD, tested in production.">
<title>Quentin Pismont — Senior/Lead Platform Engineer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Quentin Pismont — Senior/Lead Platform Engineer">
<meta property="og:description" content="Self-hosted infrastructure running in production, a security-first AI agent in Rust, and a distributed media pipeline. Source code available.">
<style>
:root {
--bg: #0d1117;
--bg-soft: #161b22;
--bg-card: #161b22;
--border: #21262d;
--border-hover: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--text-faint: #6e7681;
--accent: #58a6ff;
--accent-soft: rgba(88, 166, 255, 0.1);
--green: #3fb950;
--mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--bg: #0b0b14;
--bg-soft: #14142a;
--bg-card: #101024;
--border: #3a3a6a;
--text: #e8e8f0;
--text-dim: #bcbcd8;
--text-faint: #7a7aa4;
--accent: #3cbcfc;
--accent-soft: rgba(60, 188, 252, 0.12);
--accent-2: #f8b800;
--green: #00e436;
--pink: #ff2d78;
--mono: 'VT323', 'Courier New', monospace;
--pixel: 'Press Start 2P', 'Courier New', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
background-color: var(--bg);
/* Scanlines + grid live in the background layer, never over the glyphs */
background-image:
repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0.22) 0px,
rgba(0, 0, 0, 0.22) 1px,
transparent 1px,
transparent 4px
),
linear-gradient(rgba(60, 188, 252, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(60, 188, 252, 0.035) 1px, transparent 1px);
background-size: 100% 4px, 32px 32px, 32px 32px;
background-attachment: fixed, scroll, scroll;
color: var(--text);
font-family: var(--sans);
line-height: 1.65;
font-size: 16px;
-webkit-font-smoothing: antialiased;
font-family: var(--mono);
font-size: 21px;
line-height: 1.6;
}
/* CRT vignette — smooth gradient, does not cut through text */
body::after {
content: "";
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background: radial-gradient(ellipse at center, transparent 72%, rgba(0, 0, 0, 0.28) 100%);
}
.wrap {
max-width: 860px;
max-width: 880px;
margin: 0 auto;
padding: 0 24px;
}
@@ -45,225 +70,279 @@
position: sticky;
top: 0;
z-index: 100;
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
background: var(--bg);
border-bottom: 4px solid var(--border);
}
nav .wrap {
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
height: 64px;
}
nav .logo {
font-family: var(--mono);
font-weight: 600;
font-size: 15px;
font-family: var(--pixel);
font-weight: 400;
font-size: 10px;
color: var(--text);
text-transform: uppercase;
}
nav .logo span { color: var(--accent); }
nav .links { display: flex; gap: 28px; }
nav .logo span { color: var(--accent-2); }
nav .logo::after {
content: "_";
color: var(--accent);
animation: blink 1s steps(2, start) infinite;
}
nav .links { display: flex; gap: 24px; }
nav a {
color: var(--text-dim);
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
font-family: var(--pixel);
font-size: 8px;
text-transform: uppercase;
}
nav a:hover {
color: var(--accent-2);
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
}
nav a:hover { color: var(--text); }
@media (max-width: 620px) { nav .links { display: none; } }
/* ---------- HERO ---------- */
header {
padding: 100px 0 80px;
border-bottom: 1px solid var(--border);
padding: 72px 0 64px;
border-bottom: 4px solid var(--border);
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 13px;
gap: 10px;
font-family: var(--pixel);
font-size: 8px;
text-transform: uppercase;
color: var(--green);
background: rgba(63, 185, 80, 0.1);
border: 1px solid rgba(63, 185, 80, 0.2);
padding: 5px 12px;
border-radius: 20px;
margin-bottom: 28px;
background: transparent;
border: 3px solid var(--green);
padding: 8px 12px;
margin-bottom: 32px;
box-shadow: 4px 4px 0 rgba(0, 228, 54, 0.25);
}
.badge .dot {
width: 7px; height: 7px;
border-radius: 50%;
width: 8px; height: 8px;
background: var(--green);
box-shadow: 0 0 8px var(--green);
animation: blink 1s steps(2, start) infinite;
}
header h1 {
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15;
letter-spacing: -0.02em;
margin-bottom: 20px;
font-weight: 700;
font-family: var(--pixel);
font-size: clamp(0.85rem, 2.6vw, 1.45rem);
line-height: 1.9;
letter-spacing: 0;
margin-bottom: 28px;
font-weight: 400;
text-transform: uppercase;
text-shadow: 3px 3px 0 #000, 6px 6px 0 rgba(60, 188, 252, 0.25);
}
header h1 .grad {
background: linear-gradient(120deg, #58a6ff, #a371f7);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: var(--accent-2);
background: none;
-webkit-text-fill-color: currentColor;
}
header p.lead {
font-size: 1.15rem;
font-size: 23px;
line-height: 1.65;
color: var(--text-dim);
max-width: 640px;
max-width: 660px;
margin-bottom: 32px;
}
.cta {
display: flex;
gap: 14px;
gap: 16px;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 8px;
padding: 12px 18px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
border: 1px solid transparent;
font-family: var(--pixel);
font-size: 8px;
text-transform: uppercase;
border: 3px solid var(--text);
background: var(--bg-soft);
color: var(--text);
box-shadow: 5px 5px 0 #000;
transition: none;
}
.btn-primary {
background: var(--accent);
color: #0d1117;
color: #0b0b14;
border-color: var(--accent);
}
.btn-primary:hover {
background: var(--accent-2);
border-color: var(--accent-2);
transform: translate(2px, 2px);
box-shadow: 3px 3px 0 #000;
}
.btn-primary:hover { background: #79b8ff; transform: translateY(-1px); }
.btn-ghost {
background: transparent;
background: var(--bg-soft);
color: var(--text);
border-color: var(--border-hover);
border-color: var(--border);
}
.btn-ghost:hover {
color: var(--accent-2);
border-color: var(--accent-2);
transform: translate(2px, 2px);
box-shadow: 3px 3px 0 #000;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
/* ---------- SECTIONS ---------- */
section { padding: 80px 0; border-bottom: 1px solid var(--border); }
section { padding: 64px 0; border-bottom: 4px solid var(--border); }
.section-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 40px;
align-items: center;
gap: 14px;
margin-bottom: 36px;
}
.section-head .num {
font-family: var(--mono);
font-size: 14px;
color: var(--accent);
font-family: var(--pixel);
font-size: 10px;
color: #0b0b14;
background: var(--accent-2);
padding: 6px 8px;
box-shadow: 3px 3px 0 #000;
}
.section-head h2 {
font-size: 1.6rem;
letter-spacing: -0.01em;
font-weight: 600;
font-family: var(--pixel);
font-size: clamp(0.85rem, 2.2vw, 1.1rem);
letter-spacing: 0;
font-weight: 400;
text-transform: uppercase;
color: var(--text);
}
.section-intro {
color: var(--text-dim);
max-width: 640px;
margin: -24px 0 40px;
max-width: 660px;
margin: -20px 0 36px;
font-size: 22px;
line-height: 1.65;
}
/* ---------- ABOUT ---------- */
.about-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
gap: 36px;
}
@media (max-width: 620px) { .about-grid { grid-template-columns: 1fr; gap: 28px; } }
.about-grid p { color: var(--text-dim); margin-bottom: 16px; }
.about-grid p strong { color: var(--text); font-weight: 500; }
.about-grid p { color: var(--text-dim); margin-bottom: 18px; font-size: 22px; line-height: 1.65; }
.about-grid p strong { color: var(--accent-2); font-weight: 400; }
.principles { list-style: none; }
.principles li {
padding: 10px 0 10px 24px;
padding: 10px 0 10px 26px;
position: relative;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
border-bottom: 2px dashed var(--border);
font-size: 21px;
line-height: 1.6;
}
.principles li:last-child { border-bottom: none; }
.principles li::before {
content: "→";
content: ">";
position: absolute;
left: 0;
color: var(--accent);
color: var(--green);
font-family: var(--pixel);
font-size: 9px;
top: 14px;
}
/* ---------- PROJECTS ---------- */
.project {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 32px;
margin-bottom: 24px;
transition: border-color 0.25s, transform 0.25s;
border: 3px solid var(--border);
border-radius: 0;
padding: 28px;
margin-bottom: 28px;
box-shadow: 6px 6px 0 #000;
transition: none;
}
.project:hover {
border-color: var(--border-hover);
transform: translateY(-2px);
border-color: var(--accent);
transform: none;
box-shadow: 6px 6px 0 rgba(60, 188, 252, 0.35);
}
.project-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.project h3 {
font-size: 1.3rem;
font-weight: 600;
font-family: var(--pixel);
font-size: 11px;
line-height: 1.8;
font-weight: 400;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
flex-wrap: wrap;
color: var(--accent);
}
.project .tag {
font-family: var(--mono);
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
background: var(--accent-soft);
font-family: var(--pixel);
font-size: 7px;
padding: 6px 8px;
border-radius: 0;
background: transparent;
color: var(--accent);
border: 1px solid rgba(88,166,255,0.2);
border: 2px solid var(--accent);
white-space: nowrap;
text-transform: uppercase;
}
.tag.wip { background: rgba(210,153,34,0.1); color: #d29922; border-color: rgba(210,153,34,0.2); }
.tag.done { background: rgba(63,185,80,0.1); color: var(--green); border-color: rgba(63,185,80,0.2); }
.tag.wip { background: transparent; color: var(--accent-2); border-color: var(--accent-2); }
.tag.done { background: transparent; color: var(--green); border-color: var(--green); }
.project .subtitle {
color: var(--text-dim);
font-size: 0.98rem;
font-size: 22px;
line-height: 1.65;
margin-bottom: 24px;
}
.project .subtitle strong { color: var(--text); font-weight: 400; }
.block { margin-bottom: 20px; }
.block-label {
font-family: var(--mono);
font-size: 12px;
color: var(--text-faint);
font-family: var(--pixel);
font-size: 8px;
color: var(--accent-2);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
letter-spacing: 0;
margin-bottom: 10px;
}
.block p, .block ul { color: var(--text-dim); font-size: 0.95rem; }
.block-label::before { content: "■ "; color: var(--pink); }
.block p, .block ul { color: var(--text-dim); font-size: 21px; line-height: 1.65; }
.block ul { list-style: none; }
.block ul li {
padding: 4px 0 4px 20px;
padding: 6px 0 6px 22px;
position: relative;
}
.block ul li::before {
content: "▹";
content: ">";
position: absolute;
left: 0;
color: var(--accent);
font-size: 0.85em;
color: var(--green);
font-family: var(--pixel);
font-size: 8px;
top: 9px;
}
.block strong { color: var(--text); font-weight: 400; }
.block code {
font-family: var(--mono);
font-size: 0.85em;
background: var(--bg-soft);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.95em;
background: #000;
padding: 1px 5px;
border-radius: 0;
color: var(--accent);
border: 1px solid var(--border);
border: 2px solid var(--border);
}
.stack {
display: flex;
@@ -271,73 +350,101 @@
gap: 8px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border);
border-top: 2px dashed var(--border);
}
.stack span {
font-family: var(--mono);
font-size: 12px;
font-family: var(--pixel);
font-size: 7px;
color: var(--text-dim);
background: var(--bg-soft);
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: #000;
padding: 6px 8px;
border-radius: 0;
border: 2px solid var(--border);
text-transform: uppercase;
}
/* ---------- SKILLS ---------- */
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
}
.skill-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px;
border: 3px solid var(--border);
border-radius: 0;
padding: 20px;
box-shadow: 5px 5px 0 #000;
}
.skill-card:hover { border-color: var(--accent-2); }
.skill-card h4 {
font-size: 0.95rem;
font-family: var(--pixel);
font-size: 8px;
line-height: 1.8;
margin-bottom: 12px;
color: var(--text);
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.skill-card h4 .ic { color: var(--accent); }
.skill-card p { color: var(--text-dim); font-size: 0.9rem; }
.skill-card h4 .ic { color: var(--pink); }
.skill-card p { color: var(--text-dim); font-size: 21px; line-height: 1.6; }
/* ---------- CONTACT ---------- */
#contact { text-align: center; border-bottom: none; }
#contact h2 { justify-content: center; }
#contact .section-head { justify-content: center; }
#contact p { color: var(--text-dim); max-width: 500px; margin: 0 auto 32px; }
.contact-links { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
#contact p { color: var(--text-dim); max-width: 560px; margin: 0 auto 32px; font-size: 22px; line-height: 1.65; }
.contact-links { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
/* ---------- FOOTER ---------- */
footer {
padding: 40px 0;
padding: 32px 0;
text-align: center;
color: var(--text-faint);
font-size: 13px;
font-size: 18px;
font-family: var(--mono);
border-top: 1px solid var(--border);
border-top: 4px solid var(--border);
}
/* Reveal animation */
footer .wrap::before {
content: "PRESS START TO CONTINUE";
display: block;
font-family: var(--pixel);
font-size: 7px;
color: var(--text-faint);
margin-bottom: 14px;
animation: blink 1.4s steps(2, start) infinite;
}
footer .wrap::after {
content: "1UP HI-SCORE 000000 © 2026";
display: block;
font-family: var(--pixel);
font-size: 7px;
color: var(--text-faint);
margin-top: 14px;
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* Reveal animation: hard step, no easing */
.reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
transform: translateY(12px);
transition: opacity 0.15s steps(2, end), transform 0.15s steps(2, end);
}
.reveal.visible { opacity: 1; transform: translateY(0); }
/* Accessibility: no animation if the user prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.reveal { opacity: 1; transform: none; transition: none; }
.project:hover, .btn-primary:hover { transform: none; }
.project:hover, .btn-primary:hover, .btn-ghost:hover { transform: none; }
.badge .dot, nav .logo::after, footer .wrap::before { animation: none; }
}
</style>
</head>
<body>
<nav>
<div class="wrap">
<div class="logo">~/<span>quentin_pismont</span></div>
<div class="logo">1UP <span>QUENTIN_PISMONT</span></div>
<div class="links">
<a href="#about">About</a>
<a href="#projects">Projects</a>
@@ -348,15 +455,15 @@
</nav>
<header>
<div class="wrap">
<div class="badge"><span class="dot"></span> Open to opportunities</div>
<h1>I design systems<br><span class="grad">end to end</span>, built to hold up in production.</h1>
<div class="badge"><span class="dot"></span> Player 1 ready — open to work</div>
<h1>I design systems<br><span class="grad">end to end</span>,<br>built to hold up<br>in production.</h1>
<p class="lead">
Platform / infrastructure engineer. From code to production, through CI/CD,
observability and security — an integrated ecosystem designed to be operated
over the long run.
</p>
<div class="cta">
<a href="#projects" class="btn btn-primary">View projects →</a>
<a href="#projects" class="btn btn-primary">Start game &gt;</a>
<a href="https://gitea.qpismont.fr/explore/repos" target="_blank" class="btn btn-ghost">Gitea / Source code</a>
</div>
</div>
@@ -416,9 +523,9 @@
<!-- PROJECT 1 : INFRA -->
<div class="project reveal">
<div class="project-top">
<h3>🏗️ Self-hosted infrastructure <span class="tag done">Complete &amp; tested</span></h3>
<h3>Self-hosted infrastructure <span class="tag done">Complete &amp; tested</span></h3>
</div>
<p class="subtitle">A complete ecosystem — code → CI → production → observability — on bare metal, validated by real failure tests.</p>
<p class="subtitle">A complete ecosystem — code -> CI -> production -> observability — on bare metal, validated by real failure tests.</p>
<div class="block">
<div class="block-label">The problem</div>
<p>Having a reliable personal platform to host and operate my own services, with the standards of real production: resilience, alerting, zero data loss.</p>
@@ -427,17 +534,17 @@
<div class="block-label">The architecture</div>
<ul>
<li><strong>Networking</strong> — WireGuard (the only entry point), Docker overlay, and <code>CoreDNS</code> for <code>*.internal</code> resolution with fallback to Docker's DNS.</li>
<li><strong>CI/CD</strong> — Gitea + Woodpecker: lint → tests → build → automatic push registry on tag/release → deploy on prod.</li>
<li><strong>Observability</strong> — OtelCollector (central hub), Prometheus (storage + alerting), Loki (logs) + Tempo (traces), Perses dashboards, Alertmanager → ntfy.</li>
<li><strong>CI/CD</strong> — Gitea + Woodpecker: lint -> tests -> build -> automatic push registry on tag/release -> deploy on prod.</li>
<li><strong>Observability</strong> — OtelCollector (central hub), Prometheus (storage + alerting), Loki (logs) + Tempo (traces), Perses dashboards, Alertmanager -> ntfy.</li>
<li><strong>Safety net</strong> — Blackbox scraped directly by Prometheus: monitoring survives a Collector failure.</li>
</ul>
</div>
<div class="block">
<div class="block-label">What sets it apart — validated resilience</div>
<ul>
<li>Gitea outage → ntfy alert received ✓</li>
<li>OtelCollector outage → alert received, monitoring maintained ✓</li>
<li>Multiple redeployments → zero data loss (persistent volumes) ✓</li>
<li>Gitea outage -> ntfy alert received [OK]</li>
<li>OtelCollector outage -> alert received, monitoring maintained [OK]</li>
<li>Multiple redeployments -> zero data loss (persistent volumes) [OK]</li>
</ul>
</div>
<div class="stack">
@@ -447,7 +554,7 @@
<!-- PROJECT 2 : HERALD -->
<div class="project reveal">
<div class="project-top">
<h3>🤖 Herald — AI agent for Gitea <span class="tag wip">In active development</span></h3>
<h3>Herald — AI agent for Gitea <span class="tag wip">In active development</span></h3>
</div>
<p class="subtitle">A code-review and AI-assistance bot for Gitea, written in Rust, with a <strong>security-first</strong> agentic architecture.</p>
<div class="block">
@@ -482,7 +589,7 @@
<!-- PROJECT 3 : PIPELINE -->
<div class="project reveal">
<div class="project-top">
<h3>⚡ Distributed media pipeline <span class="tag wip">Design / upcoming</span></h3>
<h3>Distributed media pipeline <span class="tag wip">Design / upcoming</span></h3>
</div>
<p class="subtitle">An extended imgproxy-style media processing pipeline, designed for horizontal scaling and streaming.</p>
<div class="block">
@@ -516,27 +623,27 @@
</div>
<div class="skills-grid">
<div class="skill-card reveal">
<h4><span class="ic">◆</span> Languages</h4>
<h4><span class="ic">■</span> Languages</h4>
<p>Rust (async / Tokio), TypeScript / Node. Looking for the right tool for the right problem.</p>
</div>
<div class="skill-card reveal">
<h4><span class="ic">◆</span> Infrastructure &amp; Ops</h4>
<h4><span class="ic">■</span> Infrastructure &amp; Ops</h4>
<p>Docker Swarm, bare metal, Fedora Bootc (immutable OS), CI/CD (Gitea, Woodpecker).</p>
</div>
<div class="skill-card reveal">
<h4><span class="ic">◆</span> Observability</h4>
<h4><span class="ic">■</span> Observability</h4>
<p>OpenTelemetry, Prometheus, Loki, Tempo, Perses, Alertmanager. Full stack, tested under failure.</p>
</div>
<div class="skill-card reveal">
<h4><span class="ic">◆</span> Networking</h4>
<h4><span class="ic">■</span> Networking</h4>
<p>WireGuard, CoreDNS, overlay networks, Caddy. Designing self-hosted private networks.</p>
</div>
<div class="skill-card reveal">
<h4><span class="ic">◆</span> Security &amp; Isolation</h4>
<h4><span class="ic">■</span> Security &amp; Isolation</h4>
<p>Container sandboxing, privilege separation (DBus / Polkit), threat modeling.</p>
</div>
<div class="skill-card reveal">
<h4><span class="ic">◆</span> Distributed &amp; Messaging</h4>
<h4><span class="ic">■</span> Distributed &amp; Messaging</h4>
<p>NATS, distributed architectures, claim-check patterns, horizontal scaling.</p>
</div>
</div>
@@ -562,7 +669,7 @@
</section>
<footer>
<div class="wrap">
© 2026
INSERT COIN
</div>
</footer>
<script>