kert 9e111e889f
All checks were successful
ci/woodpecker/push/infra-ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
scaffold API client modules for homelab services
Base client extracted from bcda/client.py retry/auth pattern with
MockTransport injection for testing. Four service clients: Gitea,
Woodpecker, RustFS (S3 + admin), and Zotero — each with auth
headers and domain method stubs. 36 tests passing.
2026-02-28 20:01:29 -05:00
2026-02-09 23:39:08 -05:00
2026-02-07 16:18:44 -05:00
2026-02-08 22:45:09 -05:00
2026-02-08 17:41:28 -05:00
2026-02-07 16:18:44 -05:00
2026-02-08 17:41:28 -05:00

Homelab Stack

Self-hosted infrastructure stack with GPU support, S3-compatible storage, Git server with container registry, CI/CD, data lakehouse, observability, and research tools. All services share a unified retro "throwback" theme injected via Traefik middleware.

Services

All services are accessible via Traefik reverse proxy at *.homelab.fhirworx.io.

Service URL Description
Dashboard homelab.fhirworx.io Homelab command center
Traefik traefik.homelab.fhirworx.io Reverse proxy dashboard
Gitea gitea.homelab.fhirworx.io Git server with LFS + container registry
Woodpecker CI ci.homelab.fhirworx.io CI/CD pipelines
RustFS Console s3console.homelab.fhirworx.io S3 storage management UI
RustFS S3 API s3.homelab.fhirworx.io S3-compatible object storage
Notebooks notebooks.homelab.fhirworx.io GPU-accelerated Marimo notebooks
Zotero zotero.homelab.fhirworx.io Reference manager (KasmVNC)
Nessie nessie.homelab.fhirworx.io Git-like data catalog for Iceberg
Polaris polaris.homelab.fhirworx.io Apache Iceberg catalog with governance
Trino trino.homelab.fhirworx.io Distributed SQL query engine
Grafana grafana.homelab.fhirworx.io Observability dashboards
Prometheus prometheus.homelab.fhirworx.io Metrics collection
Jaeger jaeger.homelab.fhirworx.io Distributed tracing
Loki loki.homelab.fhirworx.io Log aggregation

Architecture

Network Segmentation

The stack uses five isolated Docker networks. Services only join the networks they need.

Network Type Purpose
gateway external Traefik + public-facing services
storage internal PostgreSQL + RustFS (S3)
data internal Lakehouse (Nessie, Trino, Polaris)
observability internal Grafana, Prometheus, Jaeger, Loki
ci external Woodpecker + Gitea + RustFS
                           ┌──────────────────────────┐
                           │     Traefik :80/:443      │
                           │  Reverse Proxy + Tracing  │
                           └────────────┬─────────────┘
                  gateway network       │
        ┌───────────┬──────────┬────────┼────────┬───────────┐
        ▼           ▼          ▼        ▼        ▼           ▼
   ┌────────┐ ┌─────────┐ ┌────────┐ ┌──────┐ ┌──────┐ ┌────────┐
   │ Gitea  │ │Woodpeck.│ │Notebook│ │Zotero│ │Dash- │ │Grafana │
   │ :3000  │ │  :8000  │ │ :2718  │ │:8080 │ │board │ │ :3000  │
   └───┬────┘ └────┬────┘ └────────┘ └──────┘ └──────┘ └───┬────┘
       │           │                                        │
       │     ci network                          observability network
       │           │                    ┌───────┬───────┬───┘
       │           │                    ▼       ▼       ▼
       │           │              ┌──────┐ ┌───────┐ ┌──────┐
       │           │              │Jaeger│ │Promet.│ │ Loki │
       │           │              │:16686│ │ :9090 │ │:3100 │
       │           │              └──────┘ └───────┘ └──────┘
       │           │
       └─────┬─────┘        data network
             │         ┌────────┬──────────┐
      storage network  ▼        ▼          ▼
       ┌─────┼────┐ ┌──────┐ ┌──────┐ ┌───────┐
       ▼     ▼    │ │Nessie│ │Trino │ │Polaris│
  ┌────────┐ ┌────┴──┐ │:19120│ │:8080 │ │ :8181 │
  │Postgres│ │RustFS │ └──────┘ └──────┘ └───────┘
  │ :5432  │ │:9000/1│
  └────────┘ └───────┘

Traefik Routing

Routes are defined in traefik/dynamic/services.yml using Go templates with Sprig functions. The $reef dict is the single source of truth — each entry generates a router, service, and middleware chain:

"gitea" (dict "port" "3000" "theme" true "mw" "secure-headers")
  • theme: true applies the inject-throwback middleware (injects favicon + CSS via HTML rewrite)
  • theme: false skips injection (API-only services like Nessie, Polaris, Loki)
  • mw is a comma-separated list of additional middlewares (secure-headers, local-only, infra-headers)
  • subdomain overrides the FQDN prefix (e.g. woodpecker-server → ci.homelab.fhirworx.io)

To add a new service, add one line to the $reef dict and a service block to compose.yml.

Throwback Theme

All HTML-serving services get a unified retro theme via the inject-throwback Traefik middleware, which uses the rewrite-body plugin to inject <link> tags before </head>:

  • favicon.svg — Pixel-art "H" favicon served from the dashboard container
  • throwback.css — Global CSS overrides (colors, fonts, borders)
  • inject.css — Served as throwback.css, hides service-specific logos and applies the palette

Per-service CSS files handle service-specific overrides:

File Purpose
css/inject.css Global throwback palette + logo hiding (served as throwback.css)
css/dashboard.css Dashboard tile grid layout
css/gitea.css Gitea theme (registered as throwback in Gitea UI settings)
css/woodpecker.css Woodpecker custom CSS (via WOODPECKER_CUSTOM_CSS_FILE)
css/marimo.css Marimo notebook theme
css/favicon.svg Canonical SVG favicon
css/logo.svg Navbar logo for Gitea

Grafana favicons are replaced via volume mounts over the default Grafana icons.

Prerequisites

  • Docker with rootless mode
  • NVIDIA GPU with container toolkit
  • Domain pointing to your server (or /etc/hosts entries)

Fix for rootless NVIDIA containers:

sudo sed -i 's/#no-cgroups = false/no-cgroups = true/' /etc/nvidia-container-runtime/config.toml

DNS Setup

Add to /etc/hosts (replace IP with your server's LAN IP):

192.168.1.192 homelab.fhirworx.io
192.168.1.192 dashboard.homelab.fhirworx.io
192.168.1.192 traefik.homelab.fhirworx.io
192.168.1.192 gitea.homelab.fhirworx.io
192.168.1.192 ci.homelab.fhirworx.io
192.168.1.192 notebooks.homelab.fhirworx.io
192.168.1.192 zotero.homelab.fhirworx.io
192.168.1.192 nessie.homelab.fhirworx.io
192.168.1.192 trino.homelab.fhirworx.io
192.168.1.192 s3console.homelab.fhirworx.io
192.168.1.192 s3.homelab.fhirworx.io
192.168.1.192 grafana.homelab.fhirworx.io
192.168.1.192 prometheus.homelab.fhirworx.io
192.168.1.192 jaeger.homelab.fhirworx.io
192.168.1.192 loki.homelab.fhirworx.io
192.168.1.192 polaris.homelab.fhirworx.io

Setup

1. Environment Variables

Create .env file:

DOMAIN=homelab.fhirworx.io
HOST_IP=192.168.1.192

POSTGRES_PASSWORD=<your_password>
RUSTFS_ACCESS_KEY=<your_access_key>
RUSTFS_SECRET_KEY=<your_secret_key>
GITEA_S3_ACCESS_KEY=<gitea_s3_user>
GITEA_S3_SECRET_KEY=<gitea_s3_password>
GITEA_DB_PASSWORD=<gitea_db_password>
GITEA_TOKEN=<gitea_api_token>
WOODPECKER_DB_PASSWORD=<woodpecker_db_password>
WOODPECKER_ADMIN=<admin_username>
WOODPECKER_AGENT_SECRET=<generated_secret>
WOODPECKER_GITEA_CLIENT=<oauth_client_id>
WOODPECKER_GITEA_SECRET=<oauth_client_secret>
GF_ADMIN_PASSWORD=<grafana_password>

# Nessie Data Lake
NESSIE_DB_PASSWORD=<nessie_db_password>
NESSIE_S3_ACCESS_KEY=<nessie_s3_user>
NESSIE_S3_SECRET_KEY=<nessie_s3_password>

# Polaris Catalog
POLARIS_DB_PASSWORD=<polaris_db_password>
POLARIS_ROOT_SECRET=<polaris_root_secret>
POLARIS_S3_ACCESS_KEY=<polaris_s3_user>
POLARIS_S3_SECRET_KEY=<polaris_s3_password>

Generate agent secret:

openssl rand -hex 32

2. Start Core Services

docker compose up -d postgres rustfs traefik

3. Configure RustFS

  1. Access console at s3console.homelab.fhirworx.io
  2. Create user git with S3 credentials matching .env
  3. Create buckets: gitea, gitea-lfs, gitea-packages
  4. Create user nessie with S3 credentials matching .env
  5. Create bucket: lakehouse
  6. Apply IAM policies to users (see below)

Gitea IAM Policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketLocation",
        "s3:ListBucket",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": [
        "arn:aws:s3:::gitea",
        "arn:aws:s3:::gitea-lfs",
        "arn:aws:s3:::gitea-packages"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListMultipartUploadParts",
        "s3:AbortMultipartUpload"
      ],
      "Resource": [
        "arn:aws:s3:::gitea/*",
        "arn:aws:s3:::gitea-lfs/*",
        "arn:aws:s3:::gitea-packages/*"
      ]
    }
  ]
}

Nessie IAM Policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketLocation",
        "s3:ListBucket",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": ["arn:aws:s3:::lakehouse"]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListMultipartUploadParts",
        "s3:AbortMultipartUpload"
      ],
      "Resource": ["arn:aws:s3:::lakehouse/*"]
    }
  ]
}

4. Configure PostgreSQL

Create databases and users:

# Gitea database
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE DATABASE gitea;"
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE USER git WITH PASSWORD '<gitea_db_password>'; ALTER DATABASE gitea OWNER TO git;"

# Woodpecker database
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE DATABASE woodpecker;"
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE USER woodpecker WITH PASSWORD '<woodpecker_db_password>'; ALTER DATABASE woodpecker OWNER TO woodpecker;"

# Nessie database
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE USER nessie WITH PASSWORD '<nessie_db_password>';"
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE DATABASE nessie OWNER nessie;"

# Polaris database
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE USER polaris WITH PASSWORD '<polaris_db_password>';"
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE DATABASE polaris OWNER polaris;"

5. Start All Services

docker compose up -d

6. Configure Gitea

  1. Complete initial setup at gitea.homelab.fhirworx.io
  2. Create organization homelab
  3. Create repository stack
  4. Generate API token for container registry access
  5. Create OAuth2 application for Woodpecker:
    • Name: Woodpecker CI
    • Redirect URI: http://ci.homelab.fhirworx.io/authorize
    • Copy Client ID and Secret to .env

7. Configure Woodpecker Secrets

Add secrets in Woodpecker UI (ci.homelab.fhirworx.io):

  • registry_user — Gitea username
  • registry_pass — Gitea token

8. Verify Services

# Check Traefik routes
curl -s http://localhost:8081/api/http/routers | jq -r '.[].name'

# Check Nessie
curl http://nessie.homelab.fhirworx.io/api/v2/config

# Check Trino
docker exec -it trino trino --execute "SHOW CATALOGS"

# Check Prometheus targets
curl -s http://prometheus.homelab.fhirworx.io/api/v1/targets | jq '.data.activeTargets[].labels.job'

Observability

Component Purpose
Prometheus Metrics collection and storage
Grafana Visualization and dashboards
Jaeger Distributed tracing (OTLP collector)
Loki Log aggregation
Promtail Log shipper (Docker container logs)

Services instrumented with OpenTelemetry tracing:

Service Method
Traefik Native OTLP export
Nessie Quarkus OTEL
Polaris Quarkus OTEL
Trino Native OTEL

All traces flow to Jaeger via OTLP gRPC (port 4317).

Prometheus Targets

Job Endpoint
prometheus localhost:9090
traefik traefik:8080/metrics
jaeger jaeger:14269
loki loki:3100
nessie nessie:19120/q/metrics
polaris polaris:8182/q/metrics
trino trino:8080/v1/status

Data Lakehouse

Component Purpose
Nessie Git-like version control for data (branches, merges, time travel)
Polaris Apache Iceberg catalog with governance, RBAC, and multi-tenant support
Trino Distributed SQL query engine with JDBC/ODBC support
RustFS S3 storage backend (lakehouse bucket)

Nessie vs Polaris

Feature Nessie Polaris
Git-like branching Yes No
Time travel Yes Yes
RBAC/governance Basic Full
Multi-catalog management No Yes
Iceberg REST API Yes Yes

Query Access

Method Connection
Trino CLI docker exec -it trino trino
Trino JDBC jdbc:trino://trino.homelab.fhirworx.io/iceberg
Trino Web UI trino.homelab.fhirworx.io
PyIceberg Direct to Nessie REST API
DuckDB Read Iceberg via PyArrow

Example: Create a Table

docker exec -it trino trino

CREATE SCHEMA iceberg.analytics;

CREATE TABLE iceberg.analytics.events (
    event_id VARCHAR,
    event_time TIMESTAMP(6) WITH TIME ZONE,
    user_id VARCHAR,
    event_type VARCHAR,
    properties MAP(VARCHAR, VARCHAR)
)
WITH (
    format = 'PARQUET',
    partitioning = ARRAY['day(event_time)']
);

INSERT INTO iceberg.analytics.events VALUES
    ('evt-001', CURRENT_TIMESTAMP, 'user-1', 'page_view', MAP(ARRAY['page'], ARRAY['/home']));

SELECT * FROM iceberg.analytics.events;

Directory Structure

stack/
├── compose.yml                  # Docker Compose (all services)
├── .env                         # Environment variables (not in git)
├── README.md
├── css/                         # Centralized theme assets
│   ├── inject.css               # Global throwback CSS (served as throwback.css)
│   ├── dashboard.css            # Dashboard tile grid
│   ├── gitea.css                # Gitea throwback theme
│   ├── woodpecker.css           # Woodpecker custom CSS
│   ├── marimo.css               # Marimo notebook theme
│   ├── favicon.svg              # Pixel-art "H" favicon (canonical)
│   ├── favicon.png              # 64x64 PNG favicon
│   ├── fav32.png                # 32x32 PNG favicon
│   ├── fav16.png                # 16x16 PNG favicon
│   ├── apple-touch-icon.png     # 180x180 Apple touch icon
│   ├── logo.svg                 # Navbar logo (Gitea)
│   └── logo.png                 # PNG logo (256x256)
├── nginx/
│   ├── nginx.conf               # Dashboard nginx config (CORS for theme assets)
│   └── index.html               # Dashboard HTML
├── traefik/
│   ├── traefik.yml              # Static config (entrypoints, plugins, OTEL)
│   ├── dynamic/
│   │   └── services.yml         # All routes, middlewares, services (Go template)
│   └── plugins/                 # Local rewrite-body plugin
├── gitea/
│   └── custom/                  # Gitea custom branding
├── grafana/
│   └── provisioning/            # Datasources + dashboard config
├── loki/
│   ├── loki-config.yml
│   └── promtail-config.yml
├── prometheus/
│   ├── prometheus.yml
│   └── targets/services.yml
├── trino/
│   └── etc/                     # Trino config + catalog properties
├── notebooks/                   # Marimo notebook files
├── zotero/                      # Zotero data + profiles
├── src/                         # Python source (narwhals expressions, pipes)
├── tests/                       # Test suite
├── .woodpecker/                 # CI/CD pipeline definitions
│   ├── ci.yml
│   ├── deploy.yml
│   ├── infra-ci.yml
│   └── rebuild-all.yml
└── data/                        # Shared data directory

SSH Access

Configure Git SSH access in ~/.ssh/config:

Host gitea
    HostName homelab.fhirworx.io
    Port 2222
    User git
    IdentityFile ~/.ssh/gitea_ed25519
    IdentitiesOnly yes

Generate and add SSH key:

ssh-keygen -t ed25519 -C "user@homelab" -f ~/.ssh/gitea_ed25519 -N ""
ssh-keyscan -p 2222 homelab.fhirworx.io >> ~/.ssh/known_hosts

Add public key to Gitea: gitea.homelab.fhirworx.io/user/settings/keys

Container Registry

# Login
echo "<GITEA_TOKEN>" | docker login gitea.homelab.fhirworx.io -u <username> --password-stdin

# Tag and push
docker tag <image>:latest gitea.homelab.fhirworx.io/homelab/<image>:latest
docker push gitea.homelab.fhirworx.io/homelab/<image>:latest

Ports Reference

Port Service Notes
80 Traefik HTTP All subdomain routing
443 Traefik HTTPS TLS termination
8081 Traefik API Dashboard + API (localhost only)
2222 Gitea SSH Git over SSH
3478 Zotero TURN WebRTC for KasmVNC
Description
No description provided
Readme 4.9 GiB
Languages
Python 94.2%
HTML 3%
CSS 1.4%
Go 0.5%
Shell 0.3%
Other 0.6%