Move scattered CSS files into css/ as canonical home for all styles: - inject.css (universal injectable, was nginx/throwback.css) - dashboard.css (extracted from nginx/index.html inline styles) - gitea.css, woodpecker.css, marimo.css (moved from service dirs) - throwback.css (reference palette, single source of truth) Rewire compose.yml volume mounts to source from css/.
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.
Services
All services are accessible via Traefik reverse proxy on port 80.
| Service | URL | Description |
|---|---|---|
| Dashboard | http://home.fhirworx.io | Homelab command center |
| Traefik | http://traefik.home.fhirworx.io | Reverse proxy dashboard |
| Gitea | http://gitea.home.fhirworx.io | Git server with LFS + container registry |
| Woodpecker CI | http://ci.home.fhirworx.io | CI/CD pipelines |
| RustFS Console | http://minio.home.fhirworx.io | S3 storage management UI |
| RustFS S3 API | http://s3.home.fhirworx.io | S3-compatible object storage |
| Notebooks | http://notebooks.home.fhirworx.io | GPU-accelerated Marimo notebooks |
| Zotero | http://zotero.home.fhirworx.io | Reference manager with VNC |
| Nessie | http://nessie.home.fhirworx.io | Git-like data catalog for Iceberg |
| Polaris | http://polaris.home.fhirworx.io | Apache Iceberg catalog with governance |
| Trino | http://trino.home.fhirworx.io | Distributed SQL query engine |
| Grafana | http://grafana.home.fhirworx.io | Observability dashboards |
| Prometheus | http://prometheus.home.fhirworx.io | Metrics collection |
| Jaeger | http://jaeger.home.fhirworx.io | Distributed tracing |
| Loki | http://loki.home.fhirworx.io | Log aggregation |
| PostgreSQL | localhost:5432 | Database (Bitnami hardened) |
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ intrastack network │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Traefik :80/:443 │ │
│ │ Reverse Proxy + Load Balancer + OTEL Tracing │ │
│ └───────────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┼─────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Gitea │ │Woodpecker│ │ Notebooks│ │ Zotero │ │ Dashboard│ │
│ │ :3000 │ │ :8000 │ │ :2718 │ │ :8080 │ │ :80 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL :5432 │ │
│ │ (gitea, woodpecker, nessie) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ Data Lakehouse │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────────────────┐ │ │
│ │ │ Trino │────▶│ Nessie │────▶│ RustFS │ │ │
│ │ │ :8080 │ │ :19120 │ │ S3 :9000 | Console :9001 │ │ │
│ │ └──────────┘ └──────────┘ │ Buckets: gitea, lakehouse │ │ │
│ │ │ └──────────────────────────────┘ │ │
│ │ │ ┌──────────┐ ▲ │ │
│ │ └──────────▶│ Polaris │──────────────┘ │ │
│ │ │ :8181 │ Governance + RBAC │ │
│ │ └──────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ Observability Stack │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Grafana │────▶│Prometheus│ │ Jaeger │◀────│ Loki │ │ │
│ │ │ :3000 │ │ :9090 │ │ :16686 │ │ :3100 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ ▲ ▲ │ │
│ │ └──────── Dashboards ───────────────┴───────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Prerequisites
- Docker with rootless mode
- NVIDIA GPU with container toolkit
- iptables for port 80 redirect (rootless Docker can't bind privileged ports)
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):
# Homelab Stack
192.168.1.192 home.fhirworx.io
192.168.1.192 dashboard.home.fhirworx.io
192.168.1.192 traefik.home.fhirworx.io
192.168.1.192 gitea.home.fhirworx.io
192.168.1.192 ci.home.fhirworx.io
192.168.1.192 notebooks.home.fhirworx.io
192.168.1.192 zotero.home.fhirworx.io
192.168.1.192 nessie.home.fhirworx.io
192.168.1.192 trino.home.fhirworx.io
192.168.1.192 minio.home.fhirworx.io
192.168.1.192 s3.home.fhirworx.io
192.168.1.192 grafana.home.fhirworx.io
192.168.1.192 prometheus.home.fhirworx.io
192.168.1.192 jaeger.home.fhirworx.io
192.168.1.192 loki.home.fhirworx.io
192.168.1.192 polaris.home.fhirworx.io
Port 80 Redirect
Rootless Docker cannot bind to privileged ports. Use iptables to redirect port 80 to Traefik's port 8880:
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8880
sudo iptables -t nat -A OUTPUT -p tcp --dport 80 -o lo -j REDIRECT --to-port 8880
# Persist across reboots
sudo sh -c 'iptables-save > /etc/iptables/rules.v4'
Setup
1. Environment Variables
Create .env file:
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_AGENT_SECRET=<generated_secret>
WOODPECKER_GITEA_CLIENT=<oauth_client_id>
WOODPECKER_GITEA_SECRET=<oauth_client_secret>
# 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 http://minio.home.fhirworx.io
- Create user
gitwith S3 credentials matchingGITEA_S3_ACCESS_KEYandGITEA_S3_SECRET_KEY - Create buckets:
gitea,gitea-lfs,gitea-packages - Create user
nessiewith S3 credentials matchingNESSIE_S3_ACCESS_KEYandNESSIE_S3_SECRET_KEY - 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;"
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE nessie TO 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;"
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE polaris TO polaris;"
Bootstrap Polaris
After starting Polaris, bootstrap the realm:
docker run --rm --network stack_intrastack \
-e POLARIS_PERSISTENCE_TYPE=relational-jdbc \
-e QUARKUS_DATASOURCE_DB_KIND=postgresql \
-e QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://postgres:5432/polaris \
-e QUARKUS_DATASOURCE_USERNAME=polaris \
-e QUARKUS_DATASOURCE_PASSWORD=<polaris_db_password> \
apache/polaris-admin-tool:latest \
bootstrap -r POLARIS -c POLARIS,root,<polaris_root_secret> -p
5. Start All Services
docker compose up -d
6. Configure Gitea
- Complete initial setup at http://gitea.home.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.home.fhirworx.io/authorize - Copy Client ID and Secret to
.env
- Name:
7. Configure Woodpecker Secrets
Add secrets in Woodpecker UI (http://ci.home.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.home.fhirworx.io/api/v2/config
# Check Trino
docker exec -it trino trino --execute "SHOW CATALOGS"
# Check Prometheus targets
curl -s http://prometheus.home.fhirworx.io/api/v1/targets | jq '.data.activeTargets[].labels.job'
Observability
The stack includes a complete observability solution:
Components
| Component | Purpose | Metrics |
|---|---|---|
| Prometheus | Metrics collection and storage | Scrapes all services |
| Grafana | Visualization and dashboards | Homelab Overview, Traefik |
| Jaeger | Distributed tracing | OTLP collector |
| Loki | Log aggregation | Container logs via Promtail |
| Promtail | Log shipper | Scrapes Docker container logs |
Tracing
Services instrumented with OpenTelemetry:
| Service | Tracing |
|---|---|
| Traefik | Native OTLP export |
| Nessie | Quarkus OTEL |
| Polaris | Quarkus OTEL |
| Trino | Native OTEL |
All traces flow to Jaeger via OTLP gRPC (port 4317).
Dashboards
Pre-provisioned Grafana dashboards:
- Homelab Overview - Service health, logs, traces
- Traefik - Request rates, latencies, errors by router/service
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
The stack includes a complete data lakehouse built on Apache Iceberg with two catalog options:
Components
| Component | Purpose |
|---|---|
| Nessie | Git-like version control for data (branches, merges, time travel) |
| Polaris | Apache Iceberg catalog with governance, RBAC, and multi-tenant support |
| Apache Iceberg | Open table format with ACID transactions, schema evolution |
| Trino | Distributed SQL query engine with JDBC/ODBC support |
| RustFS | S3 storage backend (lakehouse, polaris buckets) |
| PyIceberg | Python client for Iceberg tables in notebooks |
Nessie vs Polaris
| Feature | Nessie | Polaris |
|---|---|---|
| Git-like branching | ✓ | ✗ |
| Time travel | ✓ | ✓ |
| RBAC/governance | Basic | Full |
| Multi-catalog management | ✗ | ✓ |
| Iceberg REST API | ✓ | ✓ |
| Unity Catalog-like | ✗ | ✓ |
Use Nessie for data versioning, experimentation branches, and development workflows. Use Polaris for production governance, access control, and multi-tenant catalog management.
Polaris API
# Get access token
TOKEN=$(curl -s -X POST http://polaris.home.fhirworx.io/api/catalog/v1/oauth/tokens \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=root&client_secret=<polaris_root_secret>" | jq -r '.access_token')
# List catalogs
curl -s http://polaris.home.fhirworx.io/api/management/v1/catalogs \
-H "Authorization: Bearer $TOKEN"
# Create a catalog
curl -s -X POST http://polaris.home.fhirworx.io/api/management/v1/catalogs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "analytics",
"type": "INTERNAL",
"properties": {"default-base-location": "s3://polaris/analytics/"},
"storageConfigInfo": {
"storageType": "S3",
"allowedLocations": ["s3://polaris/"],
"s3": {"region": "us-east-1", "endpoint": "http://rustfs:9000", "pathStyleAccess": true}
}
}'
Query Access
| Method | Use Case | Connection |
|---|---|---|
| Trino CLI | Ad-hoc SQL queries | docker exec -it trino trino |
| Trino JDBC | External tools (DBeaver, etc.) | jdbc:trino://trino.home.fhirworx.io/iceberg |
| Trino Web UI | Query monitoring | http://trino.home.fhirworx.io |
| PyIceberg | Python notebooks | Direct to Nessie REST API |
| DuckDB | Fast local queries | Read Iceberg via PyArrow |
Example: Create a Table
-- Connect to Trino
docker exec -it trino trino
-- Create a schema
CREATE SCHEMA iceberg.analytics;
-- Create a partitioned table
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 data
INSERT INTO iceberg.analytics.events VALUES
('evt-001', CURRENT_TIMESTAMP, 'user-1', 'page_view', MAP(ARRAY['page'], ARRAY['/home']));
-- Query data
SELECT * FROM iceberg.analytics.events;
Traefik Reverse Proxy
All HTTP services are routed through Traefik using file-based configuration (not Docker labels).
Configuration
Routes are defined in traefik/dynamic/services.yml. To add a new service:
http:
routers:
myservice:
rule: "Host(`myservice.home.fhirworx.io`)"
service: myservice
entryPoints:
- web
services:
myservice:
loadBalancer:
servers:
- url: "http://myservice:8080"
Features
- OTEL Tracing - All requests traced to Jaeger
- Prometheus Metrics - Request rates, latencies by router/service
- File Provider - Routes defined in YAML, hot-reloaded
- Dashboard - http://traefik.home.fhirworx.io
TCP/UDP Routing
Traefik can also route TCP and UDP traffic. See traefik/dynamic/ for examples.
Directory Structure
stack/
├── compose.yml # Docker Compose configuration
├── .env # Environment variables (not in git)
├── .woodpecker.yml # CI/CD pipeline
├── README.md # This file
├── data/ # Shared data directory
├── gitea/
│ └── custom/
│ └── public/assets/css/
│ └── theme-throwback.css
├── grafana/
│ ├── provisioning/
│ │ ├── dashboards/
│ │ │ └── dashboards.yml
│ │ └── datasources/
│ │ └── datasources.yml
│ └── dashboards/
│ ├── homelab-overview.json
│ └── traefik.json
├── loki/
│ ├── loki-config.yml
│ └── promtail-config.yml
├── nginx/
│ ├── nginx.conf # Dashboard nginx config
│ └── index.html # Dashboard HTML
├── notebooks/
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── .marimo.toml
│ └── retro-arcade.css
├── prometheus/
│ └── prometheus.yml
├── traefik/
│ ├── traefik.yml # Static configuration
│ └── dynamic/
│ ├── middlewares.yml # Rate limiting, security headers
│ └── services.yml # All HTTP routes
├── trino/
│ └── etc/
│ ├── config.properties
│ ├── jvm.config
│ ├── node.properties
│ └── catalog/
│ └── iceberg.properties
├── woodpecker/
│ └── custom.css
└── zotero/
├── Dockerfile
└── data/
SSH Access
Configure Git SSH access in ~/.ssh/config:
Host gitea
HostName gitea.home.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 gitea.home.fhirworx.io >> ~/.ssh/known_hosts
Add public key to Gitea: http://gitea.home.fhirworx.io/user/settings/keys
Container Registry
Login:
echo "<GITEA_TOKEN>" | docker login gitea.home.fhirworx.io -u <username> --password-stdin
Tag and push:
docker tag <image>:latest gitea.home.fhirworx.io/homelab/<image>:latest
docker push gitea.home.fhirworx.io/homelab/<image>:latest
Ports Reference
| Port | Service | Notes |
|---|---|---|
| 80 | Traefik HTTP | iptables redirect to 8880 |
| 443 | Traefik HTTPS | iptables redirect to 8443 |
| 2222 | Gitea SSH | Direct access |
| 4317 | Jaeger OTLP gRPC | Direct access for external traces |
| 4318 | Jaeger OTLP HTTP | Direct access for external traces |
| 5432 | PostgreSQL | Direct access |
| 9000 | RustFS S3 API | Also via s3.home.fhirworx.io |
| 9001 | RustFS Console | Also via minio.home.fhirworx.io |