A clean, customizable homelab dashboard inspired by CasaOS. Features: - Empty-first dashboard (no demo data) - 3 themes: Light, Dark, CasaOS glassmorphism - Widgets: Clock (multi-timezone), Pi-hole, Memos, Immich, Image - Drag & drop app organization - Grid + list view for apps - Groups with collapse/expand - Proper widget refresh handling - Visual timezone picker - Square app cards with hover effects Stack: Go + Gin + PostgreSQL + Next.js 15 + React 19 + Tailwind CSS + shadcn/ui
13 KiB
BackendPlan.md
Mission
Build the complete backend for the self-hosted homelab dashboard. The backend owns persistence, OpenAPI, migrations, ordering rules, icon uploads, widget integrations, Docker runtime, and production-grade API behavior.
This agent may edit:
/backend/db/openapi- root infra/docs needed for backend integration, such as
docker-compose.yml,.env.example,Makefile, andREADME.md
This agent must not edit /frontend except to document commands or contract expectations.
Product Context
The app is a lightweight dashboard for managing self-hosted and external services. It replaces older dashboard-style tools with a modern Vercel/Nothing-inspired interface. v1 has no authentication and assumes trusted LAN/self-hosted use.
Core backend responsibilities:
- Store service cards and multiple launch URLs per service.
- Store groups, collapsed state, and drag/drop ordering.
- Store dashboard widgets and widget configuration.
- Provide one real third-party widget adapter: Pi-hole.
- Store uploaded service icons locally.
- Serve a stable OpenAPI contract so frontend can generate types/client.
Stack
- Go 1.22 or newer.
- Gin for HTTP routing.
- zap for structured logging.
- pgx for PostgreSQL access.
- PostgreSQL 16.
- goose for migrations.
- sqlc for typed query generation.
- OpenAPI 3.1 in
openapi/openapi.yaml. - Docker Compose for local/self-hosted runtime.
Repository Layout
Backend implementation should use this structure:
backend/
cmd/server/
main.go
internal/config/
internal/http/
internal/services/
internal/store/
internal/widgets/
internal/assets/
internal/validation/
internal/testutil/
db/
migrations/
query/
openapi/
openapi.yaml
Package intent:
cmd/server: process entrypoint, config load, logger, DB pool, router start.internal/config: env parsing, defaults, validation.internal/http: routes, handlers, request/response mapping.internal/services: business rules and ordering transactions.internal/store: sqlc-generated store plus thin transaction helpers.internal/widgets: widget registry, Pi-hole adapter, cache refresh logic.internal/assets: icon upload validation and local file storage.internal/validation: shared URL/name/config validation.
Runtime Configuration
Provide .env.example with:
APP_ENV=development
HTTP_ADDR=:8080
DATABASE_URL=postgres://dash:dash@postgres:5432/dash?sslmode=disable
DATA_DIR=/data
PUBLIC_BASE_URL=http://localhost:8080
WIDGET_FETCH_TIMEOUT=5s
WIDGET_CACHE_TTL=60s
MAX_ICON_UPLOAD_BYTES=524288
ALLOWED_ORIGINS=http://localhost:3000
Rules:
- Fail fast on invalid required config.
- Create icon upload directory under
DATA_DIR/icons. - Use CORS only for configured origins in development/self-hosted split runtime.
- Keep all secrets in env or database, never hardcoded.
Data Model
Use UUID primary keys, created_at, updated_at, and integer sort_order where order matters.
Tables:
groups
id uuid pk
name text not null
sort_order int not null
collapsed boolean not null default false
created_at timestamptz not null
updated_at timestamptz not null
services
id uuid pk
group_id uuid null references groups(id) on delete set null
name text not null
icon_url text null
icon_asset_id uuid null references asset_files(id) on delete set null
sort_order int not null
created_at timestamptz not null
updated_at timestamptz not null
service_urls
id uuid pk
service_id uuid not null references services(id) on delete cascade
label text not null
kind text not null check (kind in ('local', 'external', 'custom'))
url text not null
sort_order int not null
is_primary boolean not null default false
created_at timestamptz not null
updated_at timestamptz not null
widget_instances
id uuid pk
type text not null check (type in ('clock', 'image', 'pihole'))
title text not null
enabled boolean not null default true
sort_order int not null
config jsonb not null default '{}'
created_at timestamptz not null
updated_at timestamptz not null
widget_cache
widget_id uuid pk references widget_instances(id) on delete cascade
status text not null check (status in ('fresh', 'stale', 'error'))
data jsonb null
error text null
fetched_at timestamptz null
expires_at timestamptz null
updated_at timestamptz not null
asset_files
id uuid pk
original_name text not null
stored_name text not null
mime_type text not null
size_bytes int not null
public_path text not null
created_at timestamptz not null
Indexes:
groups(sort_order)services(group_id, sort_order)service_urls(service_id, sort_order)widget_instances(enabled, sort_order)asset_files(created_at)
Ordering invariant:
- Groups order independently.
- Services order inside their current
group_id; ungrouped services usegroup_id = null. - Widgets order independently.
- Reorder endpoints accept full ordered lists and persist in one transaction.
OpenAPI Contract
Backend owns openapi/openapi.yaml. Create/update it before handler implementation. Frontend must be able to generate TypeScript client/types from it.
Base path:
/api/v1
Health:
GET /health
Required endpoints:
GET /api/v1/dashboard
GET /api/v1/groups
POST /api/v1/groups
GET /api/v1/groups/{groupId}
PATCH /api/v1/groups/{groupId}
DELETE /api/v1/groups/{groupId}
GET /api/v1/services
POST /api/v1/services
GET /api/v1/services/{serviceId}
PATCH /api/v1/services/{serviceId}
DELETE /api/v1/services/{serviceId}
PUT /api/v1/layout
POST /api/v1/assets/icons
GET /api/v1/widgets
POST /api/v1/widgets
GET /api/v1/widgets/{widgetId}
PATCH /api/v1/widgets/{widgetId}
DELETE /api/v1/widgets/{widgetId}
GET /api/v1/widgets/{widgetId}/data
POST /api/v1/widgets/{widgetId}/refresh
Core response schemas:
Dashboard:
groups: Group[]
ungroupedServices: Service[]
widgets: WidgetInstance[]
Group:
id: string
name: string
sortOrder: integer
collapsed: boolean
services: Service[]
createdAt: string
updatedAt: string
Service:
id: string
groupId: string | null
name: string
iconUrl: string | null
iconAssetId: string | null
sortOrder: integer
urls: ServiceUrl[]
createdAt: string
updatedAt: string
ServiceUrl:
id: string
label: string
kind: local | external | custom
url: string
sortOrder: integer
isPrimary: boolean
WidgetInstance:
id: string
type: clock | image | pihole
title: string
enabled: boolean
sortOrder: integer
config: object
createdAt: string
updatedAt: string
Error schema:
ErrorResponse:
code: string
message: string
details: object | null
Use stable error codes:
validation_errornot_foundconflictupload_too_largeunsupported_media_typewidget_fetch_failedinternal_error
API Behavior
Dashboard:
GET /dashboardreturns all enabled/visible dashboard data in render order.- Include services nested under groups.
- Include ungrouped services separately.
- Include widgets regardless of data freshness; data comes from widget data endpoint.
Groups:
- Create group with next
sort_order. - Rename and collapsed state via PATCH.
- Delete empty group by default.
- If group contains services, return
409 conflictunless request includes explicit handling:?moveServicesToUngrouped=truemoves services to ungrouped.- No hard delete of child services in v1.
Services:
- Create/update accepts service fields and full URL list.
- A service must have at least one URL.
- URL labels must be non-empty.
- Exactly one primary URL is allowed; if omitted, first URL becomes primary.
- Patch replaces URL list atomically for simplicity.
- Delete service cascades URLs.
Layout:
PUT /layoutaccepts ordered groups, ordered widgets, and ordered services per group/ungrouped.- Validate all referenced IDs exist.
- Validate every submitted service appears once.
- Persist all ordering in one DB transaction.
- Return updated dashboard.
Assets:
POST /assets/iconsaccepts multipart fieldfile.- Allow PNG, JPEG, WebP, SVG only.
- Enforce
MAX_ICON_UPLOAD_BYTES. - Store file under
DATA_DIR/icons. - Return
AssetFilewithpublicPath. - Serve uploaded icons from
/uploads/icons/{storedName}.
Widgets:
- Widget config is JSONB but validated by widget type.
clockconfig:{ "timezones": string[] }.imageconfig:{ "imageUrl": string, "linkUrl": string | null }.piholeconfig:{ "baseUrl": string, "apiToken": string }.- Do not expose
apiTokenin normal widget responses. Return masked/omitted secret fields. GET /widgets/{id}/datareturns cache if fresh; refreshes if expired where reasonable.POST /widgets/{id}/refreshforces fetch and updates cache.
Pi-hole adapter:
- Use HTTP client with timeout from config.
- Normalize base URL and call Pi-hole summary endpoint.
- Support modern Pi-hole API where possible, but isolate endpoint details behind adapter.
- Return compact data useful for cards: blocked count, query count, percent blocked, status, fetchedAt.
- On failure, keep stale data if available and return status
stalewith error. - If no data exists, return status
errorwith[ERROR: ...]compatible message.
Validation Rules
- Names: trim whitespace, 1 to 80 chars.
- Labels: trim whitespace, 1 to 40 chars.
- URLs: absolute
httporhttpsonly for service URLs and widget URLs. - Icon URL: absolute
http/httpsor uploaded asset reference. - Reject duplicate service URL IDs in update payload.
- Reject unknown widget type.
- Reject unknown fields only if OpenAPI decoder supports this cleanly; otherwise ignore safely.
Security Posture
v1 has no authentication. Still implement baseline hardening:
- No arbitrary file paths from user input.
- Uploaded file names must be generated server-side.
- SVG uploads must be served as files, not inlined.
- Do not log Pi-hole tokens.
- CORS restricted to configured origins.
- Use request body size limits.
- Return generic internal errors to clients.
Docker and Local Run
Provide root docker-compose.yml with:
postgresbackend
Frontend service may be added by frontend agent later. Backend compose must still work alone.
Backend container:
- Multi-stage Dockerfile.
- Runs migrations before server start, or provide clear
make migrate-upused by compose entrypoint. - Exposes
8080. - Mounts persistent data volume for
/data.
Useful commands:
make backend-dev
make backend-test
make db-migrate-up
make db-migrate-down
make openapi-validate
make sqlc
docker compose up
Implementation Steps
- Scaffold Go module and backend package layout.
- Create Docker Compose, backend Dockerfile,
.env.example, Makefile targets. - Write initial goose migrations for all tables/indexes.
- Write sqlc config and queries for CRUD, dashboard loading, and ordering.
- Write
openapi/openapi.yamlwith all schemas/endpoints/errors. - Implement config, logger, DB pool, health route.
- Implement group/service/dashboard APIs.
- Implement layout transaction.
- Implement asset upload and static file serving.
- Implement widget registry, config validation, cache table logic.
- Implement Pi-hole adapter and refresh/data endpoints.
- Add backend tests and integration tests.
- Run full backend validation.
Test Plan
Unit tests:
- URL validation accepts
http/https, rejects relative and unsupported schemes. - Service create/update requires at least one URL.
- Primary URL fallback selects first URL.
- Group delete rejects non-empty group without move flag.
- Widget config validation rejects missing Pi-hole token/base URL.
- Pi-hole token masking never returns raw token.
Integration tests:
- Migrations apply cleanly to empty Postgres.
- Create group, create service with two URLs, fetch dashboard.
- Move service between groups through layout endpoint.
- Reorder groups/services/widgets in one transaction.
- Delete service cascades URLs.
- Upload valid icon, reject oversized/unsupported file.
- Widget refresh caches success and preserves stale data on later failure.
Smoke tests:
go test ./...
go vet ./...
make openapi-validate
docker compose up --build
curl http://localhost:8080/health
curl http://localhost:8080/api/v1/dashboard
Acceptance Criteria
openapi/openapi.yamlexists and is valid.- Backend compiles with
go test ./.... - Migrations run against PostgreSQL 16.
- API returns deterministic JSON matching OpenAPI.
- Dashboard data persists after container restart.
docker compose upboots backend and Postgres cleanly.- Frontend agent can generate client from OpenAPI without manual type fixes.
Parallel Coordination
- Backend can proceed before frontend exists.
- Keep breaking OpenAPI changes deliberate and documented.
- If contract changes, update
openapi/openapi.yamlfirst, then backend handlers/tests. - Do not create frontend types by hand.
- Do not move shared contract into frontend.