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.
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: trueapplies theinject-throwbackmiddleware (injects favicon + CSS via HTML rewrite)theme: falseskips injection (API-only services like Nessie, Polaris, Loki)mwis a comma-separated list of additional middlewares (secure-headers,local-only,infra-headers)subdomainoverrides 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 containerthrowback.css— Global CSS overrides (colors, fonts, borders)inject.css— Served asthrowback.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/hostsentries)
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
- Access console at
s3console.homelab.fhirworx.io - Create user
gitwith S3 credentials matching.env - Create buckets:
gitea,gitea-lfs,gitea-packages - Create user
nessiewith S3 credentials matching.env - Create bucket:
lakehouse - 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
- Complete initial setup at
gitea.homelab.fhirworx.io - Create organization
homelab - Create repository
stack - Generate API token for container registry access
- Create OAuth2 application for Woodpecker:
- Name:
Woodpecker CI - Redirect URI:
http://ci.homelab.fhirworx.io/authorize - Copy Client ID and Secret to
.env
- Name:
7. Configure Woodpecker Secrets
Add secrets in Woodpecker UI (ci.homelab.fhirworx.io):
registry_user— Gitea usernameregistry_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 |