dc1 — Pełna dokumentacja infrastruktury
Sesja z 2026-03-06/07. Kompletny opis budowy serwera dc1, k3s, tinyauth, CF Access i procesu CI/CD.
Architektura — diagram
graph TB subgraph Internet U[Użytkownik<br/>przeglądarka] end subgraph Cloudflare["Cloudflare (eiac.dev)"] CF_DNS[DNS<br/>orwil.eiac.dev<br/>auth.server.example.com<br/>server.example.com] CF_ACCESS[Cloudflare Access<br/>Zero Trust] CF_WORKER[CF Worker<br/>orwil-site] CF_R2[R2 Bucket<br/>[R2_BUCKET]] end subgraph SSO["SSO (sso.cynar.ski)"] POCKET[Pocket ID<br/>OIDC Provider] end subgraph DC1["server.example.com — Hetzner VPS ([SERVER_IP])"] subgraph K3S["k3s cluster"] TRAEFIK[Traefik v3<br/>Ingress Controller<br/>:80/:443] subgraph NS_SERVICES["namespace: services"] TINYAUTH[tinyauth<br/>:3000] TINYAUTH_CERT[TLS Secret<br/>tinyauth-tls] end subgraph NS_CERTMGR["namespace: cert-manager"] CERTMGR[cert-manager v1.19] CLUSTERISSUER[ClusterIssuer<br/>letsencrypt-prod] end end LE[Let's Encrypt<br/>ACME] end subgraph GITEA["git.example.org (Forgejo)"] VAULT_REPO[Orwil/vault<br/>Obsidian notes] SITE_REPO[Orwil/orwil-site<br/>Quartz builder] WEBHOOK[Webhook<br/>push → dispatch] PIPELINE[Forgejo Actions<br/>deploy.yml] end U -->|"1. https://orwil.eiac.dev"| CF_DNS CF_DNS --> CF_ACCESS CF_ACCESS -->|"2. brak sesji → redirect"| POCKET POCKET -->|"3. OIDC callback"| CF_ACCESS CF_ACCESS -->|"4. sesja OK → przepuść"| CF_WORKER CF_WORKER -->|"5. GET plik"| CF_R2 CF_R2 -->|"6. HTML/CSS/JS"| U U -->|"https://auth.server.example.com"| TRAEFIK TRAEFIK --> TINYAUTH TINYAUTH -->|"OAuth callback"| POCKET CERTMGR --> CLUSTERISSUER CLUSTERISSUER -->|"HTTP-01 challenge"| LE LE -->|"certyfikat"| TINYAUTH_CERT TRAEFIK --> TINYAUTH_CERT VAULT_REPO -->|"push → webhook"| WEBHOOK WEBHOOK -->|"workflow_dispatch"| PIPELINE PIPELINE -->|"checkout vault → Quartz build"| SITE_REPO PIPELINE -->|"aws s3 sync"| CF_R2
CI/CD — Quartz + Forgejo Actions
Flow publikowania
push do vault (Obsidian notes)
│
▼
Forgejo webhook (hook #482 na vault)
│ POST /api/v1/repos/Orwil/orwil-site/actions/workflows/deploy.yml/dispatches
│ body: {"ref": "main"}
│ Authorization: token <forgejo-token>
▼
Forgejo Actions — orwil-site/deploy.yml
│
├─ checkout orwil-site (Quartz)
├─ checkout vault → content/ (treść notatek)
├─ npm ci (instalacja Quartz)
├─ npx quartz build
└─ aws s3 sync ./public/ s3://[R2_BUCKET]/
endpoint: https://<account-id>.r2.cloudflarestorage.com
Plik .github/workflows/deploy.yml
name: Deploy to R2
on:
workflow_dispatch:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout orwil-site
uses: actions/checkout@v4
- name: Checkout vault
uses: actions/checkout@v4
with:
repository: Orwil/vault
token: ${{ secrets.VAULT_TOKEN }}
path: content
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Quartz
run: npx quartz build
- name: Deploy to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
run: |
aws s3 sync ./public/ s3://[R2_BUCKET]/ \
--endpoint-url https://<account-id>.r2.cloudflarestorage.com \
--deleteKonfiguracja Quartz (quartz.config.ts)
Kluczowe zmiany względem domyślnej konfiguracji:
// markdownLinkResolution: "relative" zamiast "shortest"
// "shortest" generował ../2026-03-05-img/ zamiast ./2026-03-05-img/
Plugin.CrawlLinks({ markdownLinkResolution: "relative" })Webhook
Webhook na repozytorium vault wyzwala pipeline na orwil-site przy każdym pushu:
POST https://git.example.org/api/v1/repos/Orwil/orwil-site/actions/workflows/deploy.yml/dispatches
Headers:
Authorization: token <forgejo-api-token>
Content-Type: application/json
Body: {"ref": "main"}
Poprzednie podejście (/repos/Orwil/orwil-site/dispatches) nie działało — Forgejo ignoruje custom_payload w webhookach, więc endpoint repository_dispatch nie otrzymywał wymaganego {"event_type": "..."}. Właściwe rozwiązanie: bezpośredni webhook na actions/workflows/deploy.yml/dispatches.
Serwer dc1
Specyfikacja
| Parametr | Wartość |
|---|---|
| Provider | Hetzner Cloud |
| Typ | VPS |
| OS | Ubuntu 24.04.4 LTS |
| CPU | 2 vCPU |
| RAM | 3.7 GB |
| Dysk | 38 GB SSD |
| IP | [SERVER_IP] |
| DNS | server.example.com (A, proxied: false) |
DNS bez CF proxy (proxied: false) — konieczne żeby SSH działało bezpośrednio i żeby Traefik obsługiwał TLS bez CF.
k3s — instalacja i konfiguracja
Instalacja
# Single-node, bez wbudowanego Traefika (własny przez helm)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable=traefik" sh -
# Kubeconfig
export KUBECONFIG=/etc/rancher/k3s/k3s.yamlWersja: v1.34.5+k3s1
Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# v3.20.0Namespace
kubectl create namespace services # nasze serwisyDlaczego k3s zamiast Docker Compose
k3s opłaca się gdy planujemy wiele serwisów — Traefik jako jeden ingress controller z automatycznym TLS dla każdej nowej domeny przez cert-manager, bez ręcznej konfiguracji nginx/certbot.
Traefik v3 — instalacja
helm repo add traefik https://traefik.github.io/charts
helm repo update
kubectl create namespace traefik
helm install traefik traefik/traefik \
--namespace traefik \
--set service.type=LoadBalancerWersja: v3.6.9 (chart 39.0.4)
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
traefik LoadBalancer 10.43.102.122 [SERVER_IP] 80:31099/TCP,443:31433/TCP
Pułapka: Traefik v3 zmienił schemat values — parametry
ports.web.redirectToiports.websecure.tlsz v2 już nie istnieją w v3. Instalacja bez tych flag jest poprawna.
cert-manager — instalacja i konfiguracja
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=trueWersja: v1.19.4
ClusterIssuer Let’s Encrypt
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@eiac.dev
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
ingressClassName: traefik # ← ważne: NIE 'class'Pułapka: cert-manager v1.19 wymaga
ingressClassNamew solverze. Stareclass: traefikpowoduje błąd “No such authorization” od ACME i cert-manager wchodzi w backoff na godzinę. Naprawa: usuń Certificate i stwórz nowy żeby zresetować backoff.
tinyauth — instalacja na k3s
tinyauth to lekki auth proxy z forwardAuth dla Traefik/Nginx, z obsługą OAuth/OIDC.
Secret z credentials
kubectl create secret generic tinyauth-secret \
--namespace services \
--from-literal=SECRET='<32-znakowy-hex>' # openssl rand -hex 16
--from-literal=GENERIC_CLIENT_ID='<pocket-id-client-id>' \
--from-literal=GENERIC_CLIENT_SECRET='<pocket-id-secret>'Pułapka: SECRET musi mieć dokładnie 32 znaki.
openssl rand -base64 32daje 44 znaki → fail.openssl rand -hex 16daje 32 znaki → OK.
Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: tinyauth
namespace: services
spec:
replicas: 1
selector:
matchLabels:
app: tinyauth
template:
metadata:
labels:
app: tinyauth
spec:
containers:
- name: tinyauth
image: ghcr.io/steveiliop56/tinyauth:v3
env:
- name: APP_URL
value: "https://auth.server.example.com"
- name: SECRET
valueFrom:
secretKeyRef:
name: tinyauth-secret
key: SECRET
- name: GENERIC_CLIENT_ID
valueFrom:
secretKeyRef:
name: tinyauth-secret
key: GENERIC_CLIENT_ID
- name: GENERIC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: tinyauth-secret
key: GENERIC_CLIENT_SECRET
- name: GENERIC_AUTH_URL
value: "https://sso.cynar.ski/authorize"
- name: GENERIC_TOKEN_URL
value: "https://sso.cynar.ski/api/oidc/token"
- name: GENERIC_USER_URL
value: "https://sso.cynar.ski/api/oidc/userinfo"
- name: GENERIC_SCOPES
value: "openid email profile"
- name: GENERIC_NAME
value: "Pocket ID"
- name: COOKIE_SECURE
value: "true"
ports:
- containerPort: 3000
volumeMounts:
- name: data
mountPath: /app/data
volumes:
- name: data
persistentVolumeClaim:
claimName: tinyauth-dataPułapka: tinyauth v3 używa zmiennych bez prefiksu
TINYAUTH_(APP_URL,SECRET,GENERIC_*). Nowsze wersje (v5+) używająTINYAUTH_APPURLitp. Sprawdź.env.exampledla używanego tagu obrazu.
Service + Ingress + PVC
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tinyauth-data
namespace: services
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: tinyauth
namespace: services
spec:
selector:
app: tinyauth
ports:
- port: 3000
targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tinyauth
namespace: services
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
rules:
- host: auth.server.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tinyauth
port:
number: 3000
tls:
- hosts: [auth.server.example.com]
secretName: tinyauth-tlsTraefik Middleware (forwardAuth)
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: tinyauth-auth
namespace: services
spec:
forwardAuth:
address: http://tinyauth.services.svc.cluster.local:3000/api/auth/traefik
authResponseHeaders:
- X-Auth-User
- X-Auth-EmailMiddleware tinyauth-auth można stosować dla dowolnego Ingress na dc1:
annotations:
traefik.ingress.kubernetes.io/router.middlewares: services-tinyauth-auth@kubernetescrdCloudflare Access — autoryzacja orwil.eiac.dev
Dlaczego nie tinyauth forwardauth przez CF Worker
Pierwsza próba: CF Worker wywołuje /api/auth/traefik z cookies z requestu do orwil.eiac.dev. Problem: cookie sesji tinyauth jest ustawione dla domeny auth.server.example.com. Przeglądarka nie wysyła go do orwil.eiac.dev (cross-domain). Worker zawsze widzi brak sesji → 401 → redirect na login → po zalogowaniu redirect z powrotem → brak sesji → redirect na /logout.
Rozwiązanie: Cloudflare Access (Zero Trust) — działa na poziomie Cloudflare, przed dotarciem do Workera. Cookie zarządzane przez CF w domenie cloudflareaccess.com.
Konfiguracja Pocket ID (sso.cynar.ski)
W Pocket ID należy dodać dwa OIDC klienty (lub dwa callback URL w jednym):
| Klient | Callback URL |
|---|---|
| tinyauth | https://auth.server.example.com/api/oauth/callback/pocketid |
| CF Access | https://cynarski.cloudflareaccess.com/cdn-cgi/access/callback |
Konfiguracja CF Access — Identity Provider (OIDC)
Typ: oidc
Nazwa: Pocket ID
auth_url: https://sso.cynar.ski/authorize
token_url: https://sso.cynar.ski/api/oidc/token
certs_url: https://sso.cynar.ski/.well-known/jwks.json
scopes: ["openid", "email", "profile", "groups"]
email_claim: email
claims: ["sub", "email", "email_verified", "name", "preferred_username", "groups"]
Pułapka 1:
certs_urlto/.well-known/jwks.json, nie/api/oidc/jwks— ten drugi zwraca pusty JWKS lub błąd 404.
Pułapka 2: Scope
groupsjest wymagany — bez niego Pocket ID nie wrzuca emaila do ID tokenu i CF Access rzuca “Failed to fetch user/group information from the identity provider”.
Konfiguracja CF Access — Application
Typ: Self-hosted
Domena: orwil.eiac.dev
Session: 24h
Auto-redirect: true (bezpośrednio na Pocket ID, bez strony wyboru)
Allowed IDPs: [Pocket ID]
Policy
Nazwa: Pocket ID users
Decision: allow
Include: login_method: <pocket-id-idp-id>
Każdy użytkownik który zaloguje się przez Pocket ID ma dostęp.
CF Worker (orwil-site) — bez auth
Po włączeniu CF Access Worker nie musi obsługiwać autoryzacji — CF Access blokuje requesty przed dotarciem do Workera. Worker jest czystym serwisem plików z R2:
export default {
async fetch(request, env) {
const url = new URL(request.url);
let path = url.pathname;
if (path === "/" || path === "") path = "/index.html";
let object = await env.BUCKET.get(path.replace(/^\//, ""));
if (!object && !path.includes(".")) {
const base = path.replace(/\/$/, "");
object = await env.BUCKET.get(base.replace(/^\//, "") + "/index.html")
|| await env.BUCKET.get(base.replace(/^\//, "") + ".html");
}
if (!object) return new Response("Not found", { status: 404 });
const types = { html: "text/html; charset=utf-8", css: "text/css",
js: "application/javascript", png: "image/png", /* ... */ };
const ext = path.split(".").pop().toLowerCase();
return new Response(object.body, {
headers: { "Content-Type": types[ext] || "application/octet-stream" }
});
}
};Diagram przepływu autoryzacji SSO
Gdzie co działa: Cloudflare Access i CF Worker to usługi chmurowe Cloudflare. Pocket ID działa na
sso.cynar.ski(zewnętrzny serwer). tinyauth działa na dc1 (k3s) — obsługuje osobne serwisy na*.server.example.com, nie jest używany dlaorwil.eiac.dev.
sequenceDiagram actor U as 👤 Użytkownik<br/>(przeglądarka) box Cloudflare (cloud) participant CFA as CF Access<br/>Zero Trust participant CFW as CF Worker<br/>orwil-site participant R2 as R2 Bucket<br/>[R2_BUCKET] end box sso.cynar.ski (zewnętrzny) participant PID as Pocket ID<br/>OIDC Provider end U->>CFA: GET https://orwil.eiac.dev CFA->>CFA: Sprawdź cookie CF_Authorization CFA-->>U: ❌ brak sesji → 302 redirect U->>PID: GET /authorize<br/>(scope: openid email profile groups) PID-->>U: Strona logowania (passkey / hasło) U->>PID: Logowanie PID-->>U: 302 redirect z ?code=... U->>CFA: GET /cdn-cgi/access/callback?code=... CFA->>PID: POST /api/oidc/token<br/>(exchange code → tokens) PID-->>CFA: access_token + id_token CFA->>PID: GET /.well-known/jwks.json<br/>(weryfikacja podpisu JWT) PID-->>CFA: JWKS public keys CFA->>CFA: Weryfikuj policy<br/>(login_method = Pocket ID → allow) CFA-->>U: ✅ Set-Cookie: CF_Authorization<br/>302 redirect → orwil.eiac.dev U->>CFA: GET https://orwil.eiac.dev<br/>(z cookie CF_Authorization) CFA->>CFA: ✅ Sesja ważna → przepuść CFA->>CFW: Forward request<br/>(nagłówek CF-Access-JWT-Assertion) CFW->>R2: GET plik (np. index.html) R2-->>CFW: Zawartość pliku CFW-->>U: ✅ 200 OK — HTML strony
Gdzie co jest uruchomione
| Komponent | Gdzie | Adres |
|---|---|---|
| CF Access | Cloudflare cloud | cynarski.cloudflareaccess.com |
| CF Worker (orwil-site) | Cloudflare cloud | serwuje orwil.eiac.dev |
| R2 Bucket | Cloudflare cloud | [R2_BUCKET] |
| Pocket ID | Zewnętrzny serwer | sso.cynar.ski |
| tinyauth | dc1 / k3s (Hetzner) | auth.server.example.com |
| Traefik | dc1 / k3s (Hetzner) | ingress :80/:443 |
| Gitea runner | myszka-ubuntu (lokalnie) | pipeline CI/CD |
Uwaga: tinyauth na dc1 obsługuje inne serwisy przez forwardAuth na poziomie k3s — nie jest w ścieżce dla
orwil.eiac.dev. Ta strona jest zabezpieczona wyłącznie przez CF Access.
Końcowy flow użytkownika
1. Użytkownik wchodzi na https://orwil.eiac.dev
2. CF Access sprawdza sesję (cookie CF_Authorization)
→ brak sesji: redirect na cynarski.cloudflareaccess.com
→ jest sesja: przepuść do Workera
3. CF Access → redirect na Pocket ID (sso.cynar.ski/authorize)
- scope: openid email profile groups
- redirect_uri: cynarski.cloudflareaccess.com/cdn-cgi/access/callback
4. Pocket ID: użytkownik loguje się (passkey/hasło)
→ zwraca auth code do CF Access callback
5. CF Access: exchange code → tokens (sso.cynar.ski/api/oidc/token)
- weryfikuje ID token przez JWKS (sso.cynar.ski/.well-known/jwks.json)
- pobiera email z claim
6. CF Access: sprawdza policy (login_method = Pocket ID) → allow
- ustawia cookie CF_Authorization dla cloudflareaccess.com
- redirect na orwil.eiac.dev
7. CF Worker otrzymuje request z nagłówkiem CF-Access-JWT-Assertion
- GET plik z R2
- zwraca HTML/CSS/JS
Stan klastra — snapshot
NAMESPACE NAME READY STATUS
kube-system coredns 1/1 Running
kube-system local-path-provisioner 1/1 Running
kube-system metrics-server 1/1 Running
traefik traefik-* 1/1 Running
cert-manager cert-manager-* 1/1 Running (×3)
services tinyauth-* 1/1 Running
NAMESPACE NAME HOSTS ADDRESS TLS
services tinyauth auth.server.example.com [SERVER_IP] ✅ Ready
Agent dc1 — orkiestracja
Zadania administracyjne są delegowane do agenta dc1 (GPT-4.1 przez OpenRouter) który ma dostęp do SSH i narzędzi systemowych.
Flow:
Aleksander → Orwil: "zrób X na serwerze"
Orwil → dc1 (sessions_spawn): konkretne instrukcje
dc1 → serwer (exec + SSH): wykonuje
dc1 → Orwil: raport
Orwil → Aleksander: wynik
Obserwacja: GPT-4.1 jako agent operacyjny ma tendencję do opisywania co trzeba zrobić zamiast robienia. Instalacje k3s, Traefik i cert-manager wymagały interwencji Orwila bezpośrednio przez SSH. Rekomendacja: mocniejsze instrukcje z obowiązkową weryfikacją każdego kroku.
Lekcje i pułapki (TL;DR)
| Problem | Przyczyna | Rozwiązanie |
|---|---|---|
| tinyauth SECRET error | Za długi (base64) lub za krótki | openssl rand -hex 16 → 32 znaki |
| tinyauth zmienne env | v3 NIE ma prefiksu TINYAUTH_ | Używaj APP_URL, SECRET, GENERIC_* |
| cert-manager “No such authorization” | Stare class: traefik w ClusterIssuer | Zmień na ingressClassName: traefik |
| cert-manager backoff | Po błędzie czeka godzinę | Usuń Certificate i stwórz nowy |
| Pocket ID JWKS pusty | Zły endpoint /api/oidc/jwks | Używaj /.well-known/jwks.json |
| CF Access “Failed to fetch user info” | Brak scope groups | Dodaj groups do scopes IDP |
| tinyauth forwardauth cross-domain | Cookie nie przechodzi między domenami | Użyj CF Access zamiast forwardauth |
| Traefik v3 values schema | Stare flagi z v2 nie istnieją | Instaluj bez ports.web.redirectTo |
| Forgejo webhook payload | custom_payload ignorowane | Webhook bezpośrednio na workflows/deploy.yml/dispatches |
