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 \
            --delete

Konfiguracja 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

ParametrWartość
ProviderHetzner Cloud
TypVPS
OSUbuntu 24.04.4 LTS
CPU2 vCPU
RAM3.7 GB
Dysk38 GB SSD
IP[SERVER_IP]
DNSserver.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.yaml

Wersja: v1.34.5+k3s1

Helm

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# v3.20.0

Namespace

kubectl create namespace services    # nasze serwisy

Dlaczego 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=LoadBalancer

Wersja: 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.redirectTo i ports.websecure.tls z 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=true

Wersja: 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 ingressClassName w solverze. Stare class: traefik powoduje 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 32 daje 44 znaki → fail. openssl rand -hex 16 daje 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-data

Pułapka: tinyauth v3 używa zmiennych bez prefiksu TINYAUTH_ (APP_URL, SECRET, GENERIC_*). Nowsze wersje (v5+) używają TINYAUTH_APPURL itp. Sprawdź .env.example dla 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-tls

Traefik 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-Email

Middleware tinyauth-auth można stosować dla dowolnego Ingress na dc1:

annotations:
  traefik.ingress.kubernetes.io/router.middlewares: services-tinyauth-auth@kubernetescrd

Cloudflare 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):

KlientCallback URL
tinyauthhttps://auth.server.example.com/api/oauth/callback/pocketid
CF Accesshttps://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_url to /.well-known/jwks.json, nie /api/oidc/jwks — ten drugi zwraca pusty JWKS lub błąd 404.

Pułapka 2: Scope groups jest 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 dla orwil.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

KomponentGdzieAdres
CF AccessCloudflare cloudcynarski.cloudflareaccess.com
CF Worker (orwil-site)Cloudflare cloudserwuje orwil.eiac.dev
R2 BucketCloudflare cloud[R2_BUCKET]
Pocket IDZewnętrzny serwersso.cynar.ski
tinyauthdc1 / k3s (Hetzner)auth.server.example.com
Traefikdc1 / k3s (Hetzner)ingress :80/:443
Gitea runnermyszka-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)

ProblemPrzyczynaRozwiązanie
tinyauth SECRET errorZa długi (base64) lub za krótkiopenssl rand -hex 16 → 32 znaki
tinyauth zmienne envv3 NIE ma prefiksu TINYAUTH_Używaj APP_URL, SECRET, GENERIC_*
cert-manager “No such authorization”Stare class: traefik w ClusterIssuerZmień na ingressClassName: traefik
cert-manager backoffPo błędzie czeka godzinęUsuń Certificate i stwórz nowy
Pocket ID JWKS pustyZły endpoint /api/oidc/jwksUżywaj /.well-known/jwks.json
CF Access “Failed to fetch user info”Brak scope groupsDodaj groups do scopes IDP
tinyauth forwardauth cross-domainCookie nie przechodzi między domenamiUżyj CF Access zamiast forwardauth
Traefik v3 values schemaStare flagi z v2 nie istniejąInstaluj bez ports.web.redirectTo
Forgejo webhook payloadcustom_payload ignorowaneWebhook bezpośrednio na workflows/deploy.yml/dispatches