Homelab Stack
Self-hosted infrastructure stack with GPU support, S3-compatible storage, Git server with container registry, CI/CD, data lakehouse, observability, documentation, 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) |
| Docs | docs.homelab.fhirworx.io |
API reference and CMS bibliography browser |
| 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
│ │ ▼ ┌───────┬───────┬───┘
│ │ ┌──────┐ ▼ ▼ ▼
│ │ │ Docs │ ┌──────┐ ┌───────┐ ┌──────┐
│ │ │ :80 │ │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>:
fav32.png— 32x32 PNG 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/fav32.png |
32x32 PNG favicon (canonical, injected by Traefik) |
css/favicon.png |
64x64 PNG favicon |
css/fav16.png |
16x16 PNG favicon |
css/apple-touch-icon.png |
180x180 Apple touch icon |
css/favicon.svg |
SVG favicon (legacy) |
css/logo.svg |
Navbar logo for Gitea |
css/logo.png |
256x256 PNG logo |
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 docs.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. Generate Root Key and Bootstrap Credentials
All service credentials are derived from a single 256-bit root key via HKDF-SHA256. See Credential Management for details.
# Generate root key
ROOT_KEY=$(openssl rand -hex 32)
# Add non-managed vars to .env first
cat > .env <<EOF
DOMAIN=homelab.fhirworx.io
HOST_IP=192.168.1.192
WOODPECKER_ADMIN=<admin_username>
EOF
2. Start Core Services and Bootstrap
docker compose up -d postgres rustfs traefik
# Bootstrap: creates DB roles, databases, derives all credentials, writes .env
ROOT_KEY=$ROOT_KEY uv run python -m api.auth bootstrap $(git rev-parse HEAD)
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. Start All Services
docker compose up -d
5. 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:
6. Configure Woodpecker Secrets
Add secrets in Woodpecker UI (ci.homelab.fhirworx.io):
root_key— The hex-encoded root key (enables auto-rotation on every deploy)registry_user— Gitea usernameregistry_pass— Gitea tokens3_access_key— RustFS access keys3_secret_key— RustFS secret key
7. 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 |
Credential Management
All service credentials are derived from a single 256-bit root key using HKDF-SHA256 (RFC 5869). No passwords are stored in .env — they are deterministically regenerated from the root key and a commit SHA on every deploy.
How It Works
ROOT_KEY (one Woodpecker secret) + commit_sha → HKDF-SHA256 → all service credentials → .env
The derivation uses two tiers:
| Tier | Salt | Rotates | Purpose |
|---|---|---|---|
| Bootstrap | b"bootstrap" |
Only when root key changes | Superuser passwords, OAuth2 app |
| Service | commit_sha |
Every deploy | DB passwords, S3 keys, API tokens |
Managed Credentials (18 derived, 2 skipped)
| Variable | Tier | Format | Backend |
|---|---|---|---|
POSTGRES_PASSWORD |
Bootstrap | password | env only |
GITEA_DB_PASSWORD |
Service | password | PostgreSQL (role: git) |
WOODPECKER_DB_PASSWORD |
Service | password | PostgreSQL (role: woodpecker) |
NESSIE_DB_PASSWORD |
Service | password | PostgreSQL (role: nessie) |
POLARIS_DB_PASSWORD |
Service | password | PostgreSQL (role: polaris) |
RUSTFS_ACCESS_KEY |
Service | hex | env only |
RUSTFS_SECRET_KEY |
Service | hex | env only |
GITEA_S3_*, NESSIE_S3_*, POLARIS_S3_* |
Service | hex | env only (aliases of RUSTFS) |
WOODPECKER_AGENT_SECRET |
Service | hex | env only |
POLARIS_ROOT_SECRET |
Service | password | env only |
GF_ADMIN_PASSWORD |
Service | password | env only |
GITEA_ADMIN_PASSWORD |
Service | password | Gitea (change-password) |
GITEA_TOKEN |
Service | — | Gitea (API token) |
Not managed: WOODPECKER_GITEA_CLIENT/SECRET (created once via OAuth2 app), DATABRICKS_TOKEN (external).
CLI Usage
# First-time setup: create DB roles + databases, derive all creds, write .env
ROOT_KEY=$KEY uv run python -m api.auth bootstrap $(git rev-parse HEAD)
# Rotate: derive new service credentials, update backends, rewrite .env
ROOT_KEY=$KEY uv run python -m api.auth provision $COMMIT_SHA
# Dry-run: print derived values without provisioning
ROOT_KEY=$KEY uv run python -m api.auth derive $COMMIT_SHA --redact
Auto-Rotation via CI
The provision step in .woodpecker/deploy.yml runs after every deploy, deriving fresh service-tier credentials from the commit SHA and rotating PostgreSQL passwords and Gitea tokens automatically.
Documentation Site
The docs site at docs.homelab.fhirworx.io is a Docusaurus static site that auto-generates API reference from Python docstrings and provides a searchable CMS bibliography browser.
Build Pipeline
The Dockerfile uses three stages:
- Python (
uv+griffe) — extracts docstrings fromsrc/into markdown, exportsbib.sqlitetolibrary.json - Node — runs
npm ci && npm run buildto produce static HTML - nginx:alpine — serves the built site
Auto-rebuild
- Local: a
post-commitgit hook rebuilds the docs image and restarts the container in the background after every commit - CI:
.woodpecker/deploy.ymlbuilds, scans, and pushes the docs image on every push to main (no path filter — docstrings change with anysrc/edit)
The CI build step mounts data/bib.sqlite from the host so the library browser has real data.
Library Browser
The /library page provides faceted search over the CMS bibliography (bib.sqlite). Data is exported to library.json at build time — no runtime database needed. Filters by collection, tag namespace (module:, source:, year:, rule:, sup:), and full-text search.
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
│ ├── fav32.png # 32x32 PNG favicon (canonical, injected by Traefik)
│ ├── favicon.png # 64x64 PNG favicon
│ ├── fav16.png # 16x16 PNG favicon
│ ├── apple-touch-icon.png # 180x180 Apple touch icon
│ ├── favicon.svg # SVG favicon (legacy)
│ ├── logo.svg # Navbar logo (Gitea)
│ └── logo.png # 256x256 PNG logo
├── 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
├── docs/ # Documentation site (Docusaurus)
│ ├── Dockerfile # Multi-stage: griffe extract → Node build → nginx
│ ├── docusaurus.config.js
│ ├── scripts/
│ │ ├── extract_docs.py # griffe → markdown API reference
│ │ └── export_library.py # bib.Store → library.json
│ ├── docs/ # Hand-written + auto-generated markdown
│ └── src/pages/library.tsx # CMS bibliography browser
├── notebooks/ # Marimo notebook files
├── zotero/ # Zotero data + profiles
├── src/ # Python source
│ └── api/auth/ # HKDF credential derivation + provisioning
├── tests/ # Test suite
├── .woodpecker/ # CI/CD pipeline definitions
│ ├── ci.yml # Tests + linting on push
│ ├── deploy.yml # Build, scan, push images + deploy on main
│ ├── infra-ci.yml # Dockerfile linting + build validation
│ └── rebuild-all.yml # Manual full rebuild of all images
└── 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
Custom images use the fhirworx/<svc>:<sha> naming convention locally. The registry prefix localhost:3000/ is only added in CI push steps.
# Login
echo "<GITEA_TOKEN>" | docker login localhost:3000 -u <username> --password-stdin
# Tag and push
docker tag fhirworx/<svc>:latest localhost:3000/fhirworx/<svc>:latest
docker push localhost:3000/fhirworx/<svc>:latest
Custom images: notebooks, zotero, docs. Compose resolves fhirworx/<svc>:${COMMIT_SHA:-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 |