Enterprise business portal built on Oqtane (Blazor) with .NET 10, Clean Architecture, MudBlazor UI, and full-stack service orchestration via .NET Aspire.
EnterprisePortal is a production-ready platform providing multi-tenant administration, centralized identity, job scheduling, and secure file storage — all behind a single Traefik ingress with automated TLS and mutual-TLS enforcement on admin routes.
| Framework | Oqtane 10.1.2 (Blazor) |
| Runtime | .NET 10 |
| UI | MudBlazor 9.3.0 (Material Design) |
| Architecture | Clean Architecture (Core → Domain → Application → Infrastructure) |
| Orchestration | .NET Aspire (AppHost + ServiceDefaults) |
| Identity | Zitadel (OIDC/OAuth2 — single provider for all services) |
| Ingress | Traefik v3 (ports 80/443, pluggable TLS — Let's Encrypt / Smallstep CA / user-provided, mTLS admin routes) |
| Database | PostgreSQL 16 |
| Cache | FusionCache (L1 in-memory + L2 Garnet) |
| Jobs | Hangfire (PostgreSQL backend) |
| Storage | Rustfs (S3-compatible) |
| PKI | Smallstep CA (internal certificate authority) |
| Service | Role | Auth |
|---|---|---|
| Traefik | Reverse proxy, sole ingress (80/443), Let's Encrypt DNS-01/HTTP-01, Smallstep CA internal PKI, or user-provided PEM cert; mTLS enforcement on admin routes | — |
| Zitadel | OIDC identity provider — SSO for all services | — |
| PostgreSQL | Primary relational database | Internal |
| PgAdmin | Database management UI | OAuth2 via Zitadel |
| Rustfs | S3-compatible object storage | OIDC via Zitadel |
| Garnet | Redis-compatible cache — FusionCache L2 distributed backend + Traefik dynamic routing | Internal |
| FusionCache | Two-level cache (L1 in-memory, L2 Garnet) — shared IMemoryCache with Oqtane | Internal |
| Hangfire | Background job scheduler, dashboard at /hangfire |
OIDC admin role via Portal |
| Smallstep CA | Internal mTLS certificate authority, issues certs for admin routes | OIDC provisioner via Zitadel |
All services are internal-only. Traefik is the only container exposing host ports.
Smallstep CA is the internal certificate authority. It issues client certificates used by Traefik to enforce mutual TLS on admin-facing routes.
- Traefik
tls.options: adminrequires a valid client certificate signed by the Smallstep root CA - All admin routes (
/hangfire,/pgadmin,/rustfs,/traefik,/aspire) use a dual-path pattern: mTLS client cert at priority 20 (for infra/automation), Zitadel SSO ForwardAuth at priority 10 (for browser admin access) /zitadelhas no mTLS or ForwardAuth — Zitadel self-manages its own auth (ForwardAuth would create a circular dependency)- HTTP is permanently redirected to HTTPS at the entry point level
Every admin route passes through a ForwardAuth middleware that calls Portal.Server /api/auth/validate. Requests are only forwarded if the caller has an authenticated session. Unauthenticated requests receive 401 and are redirected to the Zitadel login page.
Zitadel is the sole OIDC provider for the entire platform:
| Service | Integration |
|---|---|
| Portal | OpenID Connect (cookie session) |
| PgAdmin | OAuth2 auto-provisioning |
| Rustfs | OIDC identity federation |
| Hangfire dashboard | Role claim (admin) enforced by Portal middleware |
| Smallstep CA | OIDC provisioner (device/service cert issuance) |
On first launch, the portal redirects all requests to /setup. The setup wizard is accessible at http://localhost:8080/setup and walks through every configuration step before the portal goes live.
Steps:
- Domain & URL — Set the public domain and base URL
- TLS Provider — Choose a certificate provider:
- Let's Encrypt — DNS-01 or HTTP-01 challenge via a supported DNS provider; requires provider credentials in the next step
- Smallstep CA — Internal PKI; the wizard initialises the CA inline; short-lived 30-day certs auto-renewed by Traefik
- User-provided — Paste your own certificate and private key PEM (from an enterprise CA or self-signed); no ACME required
- DNS Credentials — Enter credentials for the selected DNS provider. This step only appears when Let's Encrypt is selected. Smallstep CA and user-provided both skip this step.
- Admin Account — Create the initial administrator (email, password, display name)
- Review & Apply — Confirm all settings; wizard provisions Zitadel OIDC, bootstraps Smallstep CA, and marks the portal as initialized. After setup completes, restart the app with
dotnet runto activate the full stack (Traefik, HTTPS, OIDC, storage services).
Once complete, FirstRunMiddleware stops intercepting requests and the portal runs normally.
Applies when the TLS provider is set to
letsencryptduring setup (or via thetls:providerconfig key).
During setup (or via --acme-resolver CLI arg), select your provider. Each provider also has a -staging variant for testing against the Let's Encrypt staging CA.
| Provider | --acme-resolver value |
Required credentials |
|---|---|---|
| AWS Route 53 | letsencrypt-dns (default) |
AWS Access Key ID, Secret Access Key, Region |
| Cloudflare | cloudflare |
DNS API Token |
| DigitalOcean | digitalocean |
Auth Token |
| Azure DNS | azure |
Client ID, Client Secret, Subscription ID, Tenant ID, Resource Group |
| GoDaddy | godaddy |
API Key, API Secret |
Append
-stagingto any resolver value (e.g.cloudflare-staging) to use the Let's Encrypt staging CA during testing.
| Page | Path | Purpose |
|---|---|---|
| System Health | /admin/health |
Live status of TLS certificate expiry (all three TLS provider types), PostgreSQL, Zitadel, and Garnet. Auto-refreshes every 60 seconds. |
| Configuration | /admin/config |
View and manage portal configuration key/value store |
| Security | /admin/security |
Zitadel OIDC status, Smallstep CA status, TLS/ACME status |
| Background Jobs | /admin/hangfire |
Link to Hangfire dashboard, job statistics |
| Backup & Recovery | /admin/backup |
Trigger and monitor backups (config, PostgreSQL, Zitadel, encryption keys) |
All admin pages require an authenticated session with the admin role.
The portal performs automated daily backups and supports manual on-demand backups:
- Config Backup —
config-backup.json— 3:00 AM UTC - PostgreSQL Dump —
backups/postgres/— 2:00 AM UTC (7-day retention) - Zitadel Export —
backups/zitadel/— 2:30 AM UTC (7-day retention) - Encryption Keys —
backups/encryption/(includessecrets.json) — 1:00 AM UTC (30-day retention)
Trigger backups on-demand via the admin API:
# Trigger a specific backup type
POST /api/admin/backup/trigger/{type}
where {type} = config | postgres | zitadel | encryption | all
# Get backup status
GET /api/admin/backup/statusThe /admin/backup page provides per-type trigger buttons and status display for recent backup operations.
- .NET 10 SDK
- Docker Desktop (or Docker Engine)
- A supported DNS provider for production TLS — configure this during setup
git clone https://github.com/Evolutionary-Networking-Designs/EnterprisePortal.git
cd EnterprisePortal
dotnet run --project src/Aspire/EnterprisePortal.AppHostThen open http://localhost:8080/setup to complete first-run configuration.
Optional: pass config via CLI instead of the setup wizard
dotnet run --project src/Aspire/EnterprisePortal.AppHost \
-- --acme-resolver cloudflare \
--portal-base-url https://yourdomain.com \
--acme-email admin@yourdomain.comOptional CLI / config.db keys
| Key | Required | Default | Description |
|---|---|---|---|
portal:base-url |
No | http://portal:8080 |
Base URL the portal is reachable at (used for ForwardAuth callbacks). Override via --portal-base-url. |
tls:provider |
No | letsencrypt |
TLS certificate provider. Values: letsencrypt | smallstep | user-provided. Override via --tls-provider. |
tls:acme-resolver |
No | letsencrypt-dns |
ACME resolver to use. Override via --acme-resolver. |
zitadel:auth-domain |
No | — | Dedicated domain for Zitadel SSO (e.g. auth.example.com). When set, Traefik routes this domain to Zitadel via Host() rule instead of using /zitadel on the portal domain. Override via --zitadel-auth-domain. |
| URL | Service | Phase |
|---|---|---|
http://localhost:8080/setup |
First Run Setup Wizard | Phase 1 (first run) |
http://localhost:8080 |
Aspire dashboard (dev only — logs, traces, resources) | Phase 1 |
https://yourdomain.com/ |
Portal (Oqtane) | Phase 2 (after setup + restart) |
https://yourdomain.com/admin/config |
Admin — Configuration | Phase 2 |
https://yourdomain.com/admin/security |
Admin — Security status | Phase 2 |
https://yourdomain.com/admin/hangfire |
Admin — Background jobs | Phase 2 |
https://yourdomain.com/hangfire |
Hangfire dashboard (mTLS client cert OR Zitadel SSO (admin)) | Phase 2 |
https://yourdomain.com/pgadmin |
PgAdmin (mTLS client cert OR Zitadel SSO (admin)) | Phase 2 |
https://yourdomain.com/rustfs |
Rustfs object storage (mTLS client cert OR Zitadel SSO (admin)) | Phase 2 |
https://yourdomain.com/traefik |
Traefik dashboard (mTLS client cert OR Zitadel SSO (admin)) | Phase 2 |
https://yourdomain.com/zitadel |
Zitadel identity provider (Zitadel self-managed auth — no mTLS, no ForwardAuth; or https://auth.example.com if zitadel:auth-domain is set) |
Phase 2 |
https://yourdomain.com/aspire |
.NET Aspire dashboard (mTLS client cert OR Zitadel SSO (admin)) | Phase 2 |
src/
├── Aspire/
│ ├── EnterprisePortal.AppHost/ # .NET Aspire orchestrator (entry point: dotnet run)
│ └── EnterprisePortal.ServiceDefaults/
├── Core/ # Abstractions: Entity, AggregateRoot, repository interfaces
├── Domain/ # Domain model: Tenant, User, Permission aggregates + events
├── Application/ # CQRS: Commands, Queries, Handlers, Validators
├── Infrastructure/ # EF Core, repositories, Hangfire, config store, caching (FusionCache L1+L2), setup services
├── Portal/
│ ├── Portal.Server/ # Oqtane host — Blazor Server, middleware, auth, admin pages
│ ├── Portal.Client/ # Blazor WebAssembly client — MudBlazor enterprise theme, layout
│ └── Portal.Shared/ # Shared types (client/server)
└── Modules/
└── EnterprisePortal.Module/ # Custom Oqtane module — Dashboard with admin summary cards
| Branch | Purpose |
|---|---|
main |
Stable, production-ready. Only receives merges from dev after CI passes. |
dev |
Integration branch. All feature branches merge here first. |
feat/{issue}-{slug} |
Individual feature work. Branch from dev; PR back to dev. |
Rules:
- No direct commits to
main. - Feature branches follow the pattern
feat/{issue-number}-{kebab-slug}(e.g.,feat/39-ci-coverage). - All PRs to
devandmainmust pass CI tests before merge.
dotnet build EnterprisePortal.slnxdotnet test src/Domain.Tests/Domain.Tests.csproj
dotnet test src/Application.Tests/Application.Tests.csproj
dotnet test src/Infrastructure.Tests/Infrastructure.Tests.csprojdotnet test src/Domain.Tests/Domain.Tests.csproj --collect:"XPlat Code Coverage" --results-directory ./TestResults/Domain
dotnet test src/Application.Tests/Application.Tests.csproj --collect:"XPlat Code Coverage" --results-directory ./TestResults/Application
dotnet test src/Infrastructure.Tests/Infrastructure.Tests.csproj --collect:"XPlat Code Coverage" --results-directory ./TestResults/InfrastructureCoverage reports (Cobertura XML) are written to ./TestResults/ and uploaded as CI artifacts on every run.
Every push and PR to dev or main triggers the CI workflow (.github/workflows/ci.yml) which builds, runs all tests, and collects code coverage via XPlat Code Coverage. PRs that fail tests are blocked from merging. Coverage artifacts are retained for 30 days.
Current Test Suite: 98 tests passing
- Domain: 37 tests
- Application: 16 tests
- Infrastructure: 45 tests
See docs/test-runs/test-run-log.md for a record of every test run and coverage over time.
Coverage targets per component are tracked in docs/test-runs/coverage-targets.md.
| Role | Agent | Responsibilities |
|---|---|---|
| Lead | Parzival | Architecture, planning, milestone ownership |
| Frontend | Art3mis | Oqtane, MudBlazor, UI/UX, admin pages, setup wizard |
| Backend | Aech | .NET, CQRS, Infrastructure, Aspire orchestration |
| Tester | Shoto | Test strategy, quality, coverage |
| Docs | Scribe | Documentation, decisions, history |
| QA/Ops | Ralph | Release, deployment, ops |
- Architectural decisions documented in
.squad/decisions.md - Issue tracking: Evolutionary-Networking-Designs/EnterprisePortal
- Agent work logged in
.squad/agents/[role]/history.md