Admin Dashboard
Platform administration dashboard for managing organizations, VPS provisioning, health monitoring, billing, and infrastructure.
The nest dashboard (apps/nest/) is a standalone Nuxt 3 application for platform-level administration. It is separate from the tenant-facing web app and provides tools for managing organizations, VPS infrastructure, billing, and platform health.
The admin dashboard is an internal tool and is not exposed to end users. It requires the platform-admin role to access.
Architecture
| Aspect | Detail |
|---|---|
| Framework | Nuxt 3 (CSR-only, ssr: false) |
| UI Layer | Extends packages/ui for shared components |
| Backend | Convex (separate deployment: apps/nest-api) |
| Infrastructure | Hetzner Cloud VPS via cloud-init provisioning |
| Auth | BetterAuth with platformAdmins table (admin/superadmin roles) |
| Port | 3001 (separate from web app on 3000) |
The admin app connects to its own Convex backend (apps/nest-api), which is a separate deployment from the tenant app (apps/api). The control plane manages VPS provisioning, billing, health monitoring, and organization lifecycle.
Authentication Flow
Auth Proxy Pattern
The admin app proxies authentication requests through a same-origin server route (server/api/auth/[...].ts) to avoid CORS issues. This route forwards requests to the Convex site URL, strips problematic headers (Host, Connection), and handles Set-Cookie domain adjustments for local development.
Browser → /api/auth/* → Nuxt server route → Convex site URL → response proxied back
Token Management
The lib/convex-auth.ts module manages JWT tokens for Convex:
- Caches the current token to avoid unnecessary auth calls
- Implements a 60-second refresh buffer (refreshes before expiry)
- Provides
getConvexAuthToken()for the Convex client's auth callback
Pages
| Route | Page | Middleware | Purpose |
|---|---|---|---|
/ | index.vue | auth | Dashboard overview: pending orgs, health alerts, revenue summary |
/login | login.vue | — | Admin sign-in (email + password) |
/unauthorized | unauthorized.vue | — | Shown when user lacks platform-admin role |
/organizations | organizations/index.vue | auth | Organization listing with status, tier, VPS health |
/organizations/[id] | organizations/[id].vue | auth | Organization detail: suspend/resume/delete/resize actions |
/organizations/new | organizations/new.vue | auth | Create new organization with tier and region selection |
/vpses | vpses/index.vue | auth | VPS infrastructure dashboard: all instances, health status |
/billing | billing/index.vue | auth | Revenue dashboard: MRR, total revenue, open/overdue invoices |
/tiers | tiers/index.vue | auth | Pricing tier configuration and management |
/admins | admins.vue | auth | Platform admin management (add/remove admins) |
/audit | audit.vue | auth | Audit log viewer for all admin actions |
/waitlist | waitlist.vue | auth | Signup approval queue: approve/reject pending organizations |
/signup | signup.vue | — | Public signup page: tier/region selection, Stripe payment |
/pending | pending.vue | — | Provisioning status page (shown to users after signup) |
/setup | setup.vue | — | Initial platform setup wizard |
Middleware
Two middleware layers protect admin routes:
auth.ts— redirects unauthenticated users to/login(with redirect query param)platform-admin.ts— queries theplatformAdminstable and redirects non-admins to/unauthorized
Organization Lifecycle
Organizations follow a state machine managed by the control plane:
States
| State | Description |
|---|---|
pending | Created via signup or admin. Awaiting payment confirmation or admin approval. |
provisioning | VPS being created on Hetzner Cloud via API. |
deploying | Server created, waiting for Docker Compose services to start (smoke test). |
active | All services running. Routing table entry active. Instance accessible. |
suspended | Admin-suspended or payment overdue. Routing disabled, VPS still running. |
cancelling | Stripe subscription cancelled. Instance remains until end of billing period. |
cancelled | Subscription ended. Instance may still exist but routing is disabled. |
deleting | VPS deletion in progress via Hetzner API. |
deleted | VPS destroyed. Routing entry removed. |
error | Provisioning or operation failed. Admin can retry or investigate. |
Admin Actions
- Suspend — Disables routing (org becomes inaccessible), VPS remains running
- Resume — Re-enables routing for a suspended org
- Change tier — Updates tier and queues a VPS resize job if server type differs
- Delete — Queues VPS deletion job, disables routing immediately
- Approve signup — Triggers VPS provisioning for a pending organization
- Reject signup — Sets organization to cancelled status
VPS Provisioning
The control plane manages VPS infrastructure through a job queue system.
Job Types
| Type | Description |
|---|---|
create | Provision a new VPS: Hetzner API → cloud-init → Docker Compose → smoke test |
resize | Change server type: power off → Hetzner resize API → power on |
delete | Destroy VPS: Hetzner delete API → remove routing entry |
redeploy | Update running services: self-update endpoint or Hetzner reboot fallback |
Jobs are queued in the provisioningJobs table and processed every 1 minute by a cron job.
Create Flow
- Admin approves signup or payment is confirmed via Stripe webhook
- Provisioning job queued with
type: 'create' - Secrets generated:
instanceSecret,convexAdminKey,mtaApiKey,mtaWebhookSecret - VPS record created in database
- Hetzner API called to create server with cloud-init userdata
- Cloud-init installs Docker, writes
.envanddocker-compose.yml, starts all services - Smoke test polls Convex (:3210) and Web (:3000) — up to 5 minutes
- Routing table entry created (slug → IPv4)
- Organization marked as
active
Available Regions
| Region | Location | Continent |
|---|---|---|
fsn1 | Falkenstein, Germany | EU |
nbg1 | Nuremberg, Germany (default) | EU |
hel1 | Helsinki, Finland | EU |
ash | Ashburn, VA, USA | US |
hil | Hillsboro, OR, USA | US |
sin | Singapore | Asia |
Per-VPS Stack
Each VPS runs six Docker services via Docker Compose:
| Service | Purpose |
|---|---|
convex | Self-hosted Convex backend (ports 3210/3211) |
web | Nuxt web application (port 3000) |
mta | Mail Transfer Agent (port 3100 API, port 25 SMTP) |
redis | Queue and cache layer |
clamav | Antivirus scanning for email attachments |
convex-deploy | One-shot schema deployment helper |
Health Monitoring
The control plane runs automated health checks every 5 minutes against all running VPS instances.
Checked Services
| Service | Endpoint | Port |
|---|---|---|
| Convex | GET /version | 3210 |
| Web | GET / | 3000 |
| MTA | GET /health | 3100 |
Status Determination
| Status | Condition |
|---|---|
healthy | All three services responding |
degraded | Convex up + at least one other service up |
unhealthy | Only one service responding |
unreachable | No services responding |
Alerting and Auto-Recovery
| Threshold | Consecutive Failures | Time | Action |
|---|---|---|---|
| Alert | 3 | ~15 min | Health alert created for admins |
| Auto-reboot | 6 | ~30 min | Hetzner server reboot triggered via API |
Health Alert Types
| Type | Description |
|---|---|
unhealthy | Instance has been failing health checks beyond the alert threshold |
auto_reboot | Automatic reboot triggered due to prolonged failure |
reboot_failed | Auto-reboot attempt failed (Hetzner API error) |
recovered | Instance returned to healthy status |
Billing and Invoicing
Pricing Tiers
| Tier | Server Type | Specs | Monthly Price |
|---|---|---|---|
| Starter | cx22 | 2 vCPU, 4 GB RAM, 40 GB disk | €28 |
| Growth | cx32 | 4 vCPU, 8 GB RAM, 80 GB disk | €60 |
| Enterprise | cx42 | 8 vCPU, 16 GB RAM, 160 GB disk | €120 |
Stripe Integration
Each tier has a corresponding Stripe Price ID. When a user signs up:
- Stripe Customer created
- Stripe Subscription created with
payment_behavior: 'default_incomplete' - Client secret returned for Stripe Elements payment confirmation
- On
invoice.paidwebhook — organization provisioning is triggered automatically - On
invoice.payment_failed— organization marked as error, user can retry
Invoice Generation
Monthly invoices are generated automatically by a daily cron job:
- Format:
INV-YYYYMM-XXXX(e.g.,INV-202603-0001) - Payment terms: 14 days from period start
- Statuses:
draft→open→paid/overdue→void - Overdue detection runs alongside invoice generation
Revenue Dashboard
The /billing page displays:
- MRR — Sum of monthly tier prices for all active organizations
- Total revenue — Sum of all paid invoices
- Open invoices — Count and total amount
- Overdue invoices — Count and total amount
Signup Flow
Self-Service (User-Initiated)
Admin Manual
Admins can create organizations directly from /organizations/new:
- Enter organization name, slug, owner email
- Select tier and region
- Provisioning job queued immediately (no payment step)
Alternatively, admins can approve pending signups from the /waitlist page:
- Review pending organization details
- Optionally change the tier
- Approve — triggers VPS provisioning
- Reject — sets organization to cancelled with optional reason
Control Plane Backend (apps/nest-api)
The control plane is a separate Convex deployment managing all platform infrastructure.
Key Modules
| Module | Purpose |
|---|---|
organizations.ts | Organization CRUD, lifecycle transitions, signup approval |
vpses.ts | VPS instance queries and mutations |
provisioning.ts | Job processor: create, resize, delete, redeploy VPS operations |
provisioningJobs.ts | Job queue management (queued → running → succeeded/failed) |
healthChecks.ts | Automated health monitoring and auto-recovery |
billing.ts | Invoice generation, MRR calculation, overdue detection |
stripe.ts | Stripe subscription creation for self-service signup |
tiers.ts | Pricing tier management and seeding |
routing.ts | Routing table management for Caddy reverse proxy |
auditLogs.ts | Platform admin action audit trail |
platformAdmins.ts | Admin user management (admin/superadmin roles) |
Cron Jobs
| Job | Interval | Function |
|---|---|---|
| Health checks | 5 min | healthChecks.checkAll |
| Process provisioning jobs | 1 min | provisioning.processJobs |
| Generate monthly invoices | 24 hours | billing.generateMonthlyInvoices |
Composables
| Composable | Purpose |
|---|---|
useAuth() | BetterAuth session management: signInWithEmail(), signOut(), waitUntilReady(), refetch(). Exposes reactive status (pending/authenticated/unauthenticated/error). |
useConvexQuery() | Reactive Convex query wrapper. Returns { data, error, isLoading }. Deep-watches args for reactivity. Supports 'skip' pattern for conditional queries. |
useConvexMutation() | Convex mutation executor. Returns { mutate, isLoading, error }. |
useConvex() | Base composable returning the Convex client instance. |
Query Usage Pattern
const { data: organizations, isLoading } = useConvexQuery(
api.organizations.list,
() => ({ status: selectedStatus.value })
)
The args factory function is deep-watched — when selectedStatus changes, the query automatically re-subscribes with new arguments.
Environment Variables
Admin Dashboard (apps/nest/.env)
| Variable | Description | Default |
|---|---|---|
NUXT_PUBLIC_CONVEX_URL | Convex deployment URL for the nest-api deployment | — |
NUXT_PUBLIC_CONVEX_SITE_URL | Convex site URL for the nest-api deployment (used for auth proxy) | — |
NUXT_PUBLIC_SITE_URL | Admin dashboard URL | http://localhost:3001 |
Control Plane Backend (nest-api Convex dashboard)
| Variable | Description |
|---|---|
BETTER_AUTH_SECRET | Session signing secret for admin auth |
SITE_URL | Admin dashboard URL for auth redirects |
HETZNER_API_TOKEN | Hetzner Cloud API token for VPS provisioning |
HETZNER_SSH_KEY_IDS | Comma-separated SSH key IDs for server access |
STRIPE_SECRET_KEY | Stripe API secret key |
STRIPE_WEBHOOK_SECRET | Stripe webhook signing secret |
STRIPE_PRICE_STARTER | Stripe Price ID for Starter tier |
STRIPE_PRICE_GROWTH | Stripe Price ID for Growth tier |
STRIPE_PRICE_ENTERPRISE | Stripe Price ID for Enterprise tier |
Security
The admin app is configured with strict security headers via nuxt-security:
- CSP — strict
script-src,object-src: 'none', Convex URL inconnect-src - HSTS — enabled with long max-age
- X-Frame-Options —
DENY(prevents embedding) - Referrer-Policy —
strict-origin-when-cross-origin
Key Files
Admin Dashboard (apps/nest/)
| File | Purpose |
|---|---|
nuxt.config.ts | App configuration, security headers, runtime config |
app/lib/auth-client.ts | BetterAuth client initialization |
app/lib/convex-auth.ts | JWT token caching and refresh |
app/plugins/convex.client.ts | Convex client initialization and auth wiring |
server/api/auth/[...].ts | Auth proxy server route |
app/middleware/auth.ts | Authentication middleware |
app/middleware/platform-admin.ts | Platform admin role middleware |
app/layouts/default.vue | Sidebar layout with navigation |
Control Plane Backend (apps/nest-api/)
| File | Purpose |
|---|---|
convex/schema.ts | Control plane database schema (9 tables) |
convex/provisioning.ts | VPS create/resize/delete/redeploy + cloud-init generation |
convex/healthChecks.ts | Health check logic, alerting, auto-reboot |
convex/billing.ts | Invoice generation, MRR calculation |
convex/stripe.ts | Stripe subscription creation for signups |
convex/organizations.ts | Organization CRUD and lifecycle management |
convex/tiers.ts | Tier definitions and seeding |
convex/crons.ts | Cron job definitions (health, provisioning, billing) |
convex/lib/regions.ts | Hetzner region definitions (6 datacenters) |
Infrastructure (infra/)
| File | Purpose |
|---|---|
pulumi/src/vps.ts | Pulumi component for Hetzner VPS provisioning |
pulumi/src/cloudinit.ts | Cloud-init script generator |
templates/docker-compose.vps.yml | Per-VPS Docker Compose template |
templates/.env.vps.template | Per-VPS environment variable template |
caddy/ | Caddy reverse proxy configuration |