The Vision: StackDock is infrastructure's WordPress moment. A composable, open-source multi-cloud management platform where you own the code.
- Core Philosophy
- The Three Registries
- Universal Table Architecture
- Data Model
- Security Architecture
- RBAC System
- Dock Adapter Pattern
- Tech Stack
- Monorepo Structure
- Development Priority
shadcn/ui revolutionized UI development:
npx shadcn add button
# → Copies button.tsx into YOUR codebase
# → You OWN the code, not a dependency
# → You can modify it
# → No vendor lock-inStackDock does the same for infrastructure:
npx stackdock add gridpane
# → Copies GridPane dock adapter into YOUR codebase
# → You OWN the infrastructure adapter
# → You can modify it for your needs
# → You can publish your own version
# → No vendor lock-in| Tool | Limitation |
|---|---|
| Terraform | Code-first, not a UI platform |
| CloudQuery | Read-only, no mutations |
| AWS Console | Vendor-locked, single provider |
| Pulumi | Developer tool, not operator interface |
| Grafana | Monitoring only, not management |
StackDock fills the gap: Universal control plane with true code ownership.
┌─────────────────────────────────────────────────────────────┐
│ StackDock Platform │
│ (Orchestration Layer) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ RBAC │ │ Encryption │ │ Audit │ │
│ │ System │ │ (AES-256) │ │ Logging │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Universal Tables (convex/schema.ts) │ │
│ │ servers | webServices | domains | databases | ... │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Docks │ │ UI │ │ CLI │
│ Registry │ │ Registry │ │ Tool │
│ │ │ │ │ │
│ packages/ │ │ packages/ │ │ packages/ │
│ docks/ │ │ ui/ │ │ cli/ │
│ │ │ │ │ │
│ Copy/Paste/ │ │ Copy/Paste/ │ │ Install │
│ Own │ │ Own │ │ Components │
└──────────────┘ └──────────────┘ └──────────────┘
What it is: A registry of provider adapters that translate APIs to universal schema.
How it works:
- Community publishes dock adapters
- You install via CLI:
npx stackdock add provider-name - Adapter code copied to your repo
- You can fork, modify, republish
Examples:
gridpanedock → Translates GridPane API towebServicestableverceldock → Translates Vercel API towebServicestabledigitaloceandock → Translates DO API toserverstablecloudflaredock → Translates Cloudflare API todomainstable
What it is: A registry of dashboard widgets (shadcn/ui model).
How it works:
- Community publishes widgets
- You install via CLI:
npx stackdock add widget-name - Component code copied to your repo
- Works with ANY provider (uses universal tables)
Examples:
server-health-widget→ Works with AWS, DigitalOcean, Vultr serversdeployment-timeline→ Works with Vercel, Netlify, Railwaydomain-status-card→ Works with Cloudflare, Route53, Namecheap
What it is: The core StackDock platform that provides:
- Universal data model (schema.ts)
- RBAC enforcement
- Encryption & security
- Audit logging
- Real-time sync
- Resource linking (polymorphic)
This is the foundation that makes registries possible.
Provider APIs Universal Tables
───────────────── ────────────────────
GridPane API ────┐
│
Vercel API ──────┤
│
Netlify API ─────┼───┐
│ │
Cloudflare API ──┘ │
│
Coolify API ─────────┼───┐
│ │
│ │
▼ ▼
┌─────────────────┐
│ webServices │
│ (Universal) │
└─────────────────┘
│
│
▼
┌─────────────────┐
│ fullApiData │
│ (Provider- │
│ specific) │
└─────────────────┘
❌ WRONG APPROACH:
// This doesn't scale
gridPaneSites: defineTable({
name: v.string(),
phpVersion: v.string(),
gridpaneSpecificField: v.string(),
// ... 50 GridPane-specific fields
})
vercelDeployments: defineTable({
name: v.string(),
framework: v.string(),
vercelSpecificField: v.string(),
// ... 50 Vercel-specific fields
})Why this fails:
- Dashboard needs different code for each provider
- Can't link resources across providers
- Doesn't scale (100 providers = 100 tables)
- Vendor lock-in (tied to specific APIs)
✅ CORRECT APPROACH:
webServices: defineTable({
// Universal fields (common to ALL providers)
orgId: v.id("organizations"),
dockId: v.id("docks"),
provider: v.string(), // "gridpane", "vercel", "railway"
providerResourceId: v.string(), // Provider's internal ID
name: v.string(), // Universal: display name
productionUrl: v.string(), // Universal: URL
status: v.string(), // Universal: running/stopped/error
gitRepo: v.optional(v.string()), // Universal: git repository
// Provider-specific data (catch-all)
fullApiData: v.any(), // ALL provider-specific fields
})Why this works:
- Dashboard is provider-agnostic: Queries
webServices, shows name/url/status for ALL - Cross-provider linking: Projects can link GridPane site + Vercel deployment
- Scales infinitely: 1000 providers = same table, just different
providerfield - Extensible: Access provider-specific fields via
fullApiData.phpVersion
| Table | Purpose | Providers |
|---|---|---|
servers |
IaaS compute | AWS EC2, DigitalOcean, Vultr, Hetzner, Linode |
webServices |
PaaS apps | Vercel, Netlify, Railway, Render, GridPane sites |
domains |
DNS management | Cloudflare, Route53, Namecheap |
databases |
Managed databases | AWS RDS, PlanetScale, Supabase, Neon |
Layer 1-3: Multi-Tenancy & Identity
├── organizations (top-level tenant)
├── users (synced from Clerk)
├── memberships (user ↔ org + role)
├── teams (internal groups)
└── clients (external groups, for agencies)
Layer 4: Docks (Provider Connections)
├── docks (provider credentials, encrypted)
└── dockPermissions (team/client access control)
Layer 5: Universal Resources
├── servers (IaaS compute)
├── webServices (PaaS apps)
├── domains (DNS zones)
└── databases (managed DBs)
Layer 5b: Projects (Resource Grouping)
├── projects (logical grouping)
└── projectResources (polymorphic links to resources)
Layer 7: RBAC
├── roles (permission sets)
├── teamMemberships (user ↔ team + role)
└── clientMemberships (user ↔ client + role)
Layer 6: Operations (Future)
├── operationServices (shared services like backups)
└── operationPermissions (team access)
Organization
├── has many Users (via memberships)
├── has many Teams
├── has many Clients
├── has many Docks
├── has many Projects
└── has many Resources
Project
├── belongs to Organization
├── belongs to Team
├── belongs to Client
└── links to Resources (polymorphic)
Resource (server/webService/domain)
├── belongs to Organization
├── belongs to Dock
└── can be linked by Projects
Dock
├── belongs to Organization
├── has encrypted API key
└── syncs Resources
The Pattern:
projectResources: defineTable({
projectId: v.id("projects"),
// Polymorphic fields
resourceTable: v.union(
v.literal("servers"),
v.literal("webServices"),
v.literal("domains")
),
resourceId: v.string(), // ID in the resource table
// Denormalized for performance
denormalized_name: v.string(),
denormalized_status: v.string(),
})Why this works:
- One project can link to servers (AWS), webServices (Vercel), domains (Cloudflare)
- Resources can be from DIFFERENT docks/providers
- Dashboard shows unified view regardless of provider
Example:
Project: "Client A Website"
├── Server (DigitalOcean droplet)
├── WebService (Vercel deployment)
└── Domain (Cloudflare zone)
Assets to Protect:
- API keys for docks (AWS, GridPane, Vercel) - CROWN JEWELS
- User data (names, emails)
- Resource metadata
- Audit logs
Attack Vectors:
- Compromised user account
- SQL injection (N/A: using Convex)
- XSS attacks
- API key exposure
- Insufficient RBAC (horizontal privilege escalation)
- JWT-based authentication
- MFA support
- Session management
- Webhook for user sync
Implementation:
// convex/lib/encryption.ts
export async function encryptApiKey(plaintext: string): Promise<Uint8Array> {
const iv = webcrypto.getRandomValues(new Uint8Array(12))
const key = await webcrypto.subtle.importKey(
'raw',
Buffer.from(process.env.ENCRYPTION_MASTER_KEY!, 'hex'),
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
)
const encrypted = await webcrypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext)
)
// Return: IV (12 bytes) + ciphertext
return new Uint8Array([...iv, ...new Uint8Array(encrypted)])
}Key Storage:
- Master key in environment variable (64-char hex)
- Rotate master key quarterly
- Support for multiple key versions (graceful rotation)
Never Exposed:
- API keys never sent to client
- Decryption only in Convex server functions
- No logging of decrypted values
Zero-Trust Model: Every operation validates:
- User is authenticated
- User belongs to organization
- User has required permission
- Resource belongs to user's org (no cross-org access)
Enforcement Points:
- Convex middleware (global)
- Resource-level checks (fine-grained)
- Client-side guards (UI only, not security)
What we log:
- All mutations (create, update, delete)
- RBAC decisions (granted/denied with reason)
- Authentication events (login, logout, failed attempts)
- Dock syncs (success/failure)
Schema:
auditLogs: defineTable({
orgId: v.id("organizations"),
userId: v.id("users"),
action: v.string(), // "dock.create", "project.update"
resourceType: v.optional(v.string()),
resourceId: v.optional(v.string()),
metadata: v.any(), // Action-specific data
result: v.union(v.literal("success"), v.literal("error")),
timestamp: v.number(),
})- HTTPS only (enforced)
- CSP headers (prevent XSS)
- CORS properly configured
- Rate limiting on Convex mutations
- Webhook signature verification (Clerk)
Permissions are hierarchical:
none: No accessread: View onlyfull: Read + write
Resources:
projects: Create/edit/delete projectsresources: Manage servers/sites/domainsdocks: Connect/disconnect providersoperations: Backup/restore operationssettings: Org/team/role management
Example Role:
{
name: "Developer",
permissions: {
projects: "full", // Can create/edit projects
resources: "read", // Can view resources (read-only)
docks: "none", // Cannot access docks
operations: "read", // Can view operation logs
settings: "none", // Cannot change settings
}
}// convex/lib/rbac.ts
export function withRBAC(permission: string) {
return (handler: any) => async (ctx: MutationCtx, args: any) => {
const user = await getCurrentUser(ctx)
const hasPermission = await checkPermission(
ctx,
user._id,
args.orgId,
permission
)
if (!hasPermission) {
// Log denial
await auditLog(ctx, "rbac.deny", "error", {
permission,
userId: user._id
})
throw new ConvexError(`Permission denied: ${permission}`)
}
// Log grant
await auditLog(ctx, "rbac.grant", "success", { permission })
return handler(ctx, args, user)
}
}export const createDock = mutation({
args: { orgId: v.id("organizations"), ... },
handler: withRBAC("docks:full")(async (ctx, args, user) => {
// User has been validated
// Safe to proceed
return await ctx.db.insert("docks", { ... })
}),
})Every query filters by orgId:
export const listServers = query({
args: { orgId: v.id("organizations") },
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx)
// Verify user belongs to org
const membership = await ctx.db
.query("memberships")
.withIndex("by_org_user", q =>
q.eq("orgId", args.orgId).eq("userId", user._id)
)
.first()
if (!membership) throw new ConvexError("Not a member")
// Only return org's servers
return await ctx.db
.query("servers")
.withIndex("by_orgId", q => q.eq("orgId", args.orgId))
.collect()
},
})Dock Permissions:
// Grant team access to specific dock
await ctx.db.insert("dockPermissions", {
dockId: "dock_123",
teamId: "team_456",
clientId: undefined, // Not for clients
})
// Query docks accessible to team
const permissions = await ctx.db
.query("dockPermissions")
.withIndex("by_teamId", q => q.eq("teamId", teamId))
.collect()Client Portal:
- Clients only see docks granted via
dockPermissions - Clients only see resources from those docks
- Read-only by default
┌─────────────┐
│ User │
│ Clicks │
│ "Sync" │
└──────┬──────┘
│
▼
┌─────────────────────┐
│ Dock Adapter │
│ (convex/docks/ │
│ adapters/) │
│ │
│ 1. Decrypt API Key │
│ 2. Call Provider │
│ API │
│ 3. Transform Data │
│ (Provider → │
│ Universal) │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ Universal Table │
│ (servers, │
│ webServices, │
│ domains, etc.) │
└─────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ StackDock Universal Tables │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ servers │ │webServices│ │ domains │ │databases │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
│ │ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ │ │ │ │ │ │ │
┌───┴───┐ ┌──┴──┐ ┌┴───┐ ┌──┴──┐ ┌┴───┐ ┌──┴──┐ ┌┴───┐ ┌──┴──┐
│GridPane│ │Vultr│ │Verc│ │Netl│ │Cloud│ │Grid│ │Turso│ │Neon │
│ │ │ │ │el │ │ify │ │flare│ │Pane│ │ │ │ │
└────────┘ └─────┘ └────┘ └────┘ └─────┘ └─────┘ └─────┘ └─────┘
┌───┴───┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐
│Digital│ │Lino│ │Hetz │ │Cooli│ │GitHu│ │Sentr│ │Bette│ │Conve│
│Ocean │ │de │ │ner │ │fy │ │b │ │y │ │r │ │x │
└───────┘ └────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘
Every dock adapter implements:
// convex/docks/_types.ts
export interface DockAdapter {
provider: string
// Validate credentials (called before saving)
validateCredentials(apiKey: string): Promise<boolean>
// Sync resources to universal tables
syncWebServices(ctx: MutationCtx, dock: Doc<"docks">): Promise<void>
syncServers(ctx: MutationCtx, dock: Doc<"docks">): Promise<void>
syncDomains(ctx: MutationCtx, dock: Doc<"docks">): Promise<void>
// Future: Mutations (optional)
restartServer?(ctx: MutationCtx, serverId: string): Promise<void>
deploySite?(ctx: MutationCtx, siteId: string): Promise<void>
}// convex/docks/adapters/gridpane.ts
export async function syncWebServices(ctx: MutationCtx, dock: Doc<"docks">) {
// 1. Decrypt API key (server-side only)
const apiKey = await decryptApiKey(dock.encryptedApiKey)
// 2. Call provider API
const response = await fetch('https://my.gridpane.com/oauth/api/v1/sites', {
headers: { 'Authorization': `Bearer ${apiKey}` }
})
const { data: sites } = await response.json()
// 3. Translate to universal schema
for (const site of sites) {
const existing = await ctx.db
.query("webServices")
.withIndex("by_dockId", q => q.eq("dockId", dock._id))
.filter(q => q.eq(q.field("providerResourceId"), site.id.toString()))
.first()
const serviceData = {
orgId: dock.orgId,
dockId: dock._id,
// Universal fields
provider: "gridpane",
providerResourceId: site.id.toString(),
name: site.name,
productionUrl: site.primary_domain || site.name,
status: site.status || "unknown",
gitRepo: site.git_repo,
// Provider-specific data
fullApiData: site, // phpVersion, backups, etc.
}
// 4. Upsert (update or insert)
if (existing) {
await ctx.db.patch(existing._id, serviceData)
} else {
await ctx.db.insert("webServices", serviceData)
}
}
}| Provider Field | Universal Field | Notes |
|---|---|---|
GridPane site.name |
name |
Direct mapping |
GridPane site.primary_domain |
productionUrl |
GridPane-specific |
Vercel project.name |
name |
Direct mapping |
Vercel ${name}.vercel.app |
productionUrl |
Computed |
DO droplet.name |
name |
Direct mapping |
DO droplet.networks.v4[0].ip |
ipAddress |
Nested field |
The Adapter's Job: Make provider API look like universal schema.
GridPane Example:
- GET requests: 12/min per endpoint
- PUT requests: 2/min account-wide
Implementation:
class GridPaneClient {
private lastRequest: Record<string, number> = {}
async fetch(endpoint: string) {
const now = Date.now()
const lastCall = this.lastRequest[endpoint] || 0
const timeSinceLastCall = now - lastCall
if (timeSinceLastCall < 5000) { // 5 seconds
await new Promise(resolve => setTimeout(resolve, 5000 - timeSinceLastCall))
}
this.lastRequest[endpoint] = Date.now()
return fetch(`https://my.gridpane.com/oauth/api/v1${endpoint}`, ...)
}
}Why TanStack Start:
- Modern React framework (like Next.js but lighter)
- File-based routing
- Server Components
- TypeScript-first
- Flexibility (not opinionated like Next.js)
Structure:
apps/web/src/
├── routes/
│ ├── __root.tsx # Root layout (providers)
│ ├── index.tsx # Landing page
│ └── dashboard/
│ ├── _layout.tsx # Dashboard layout (auth guard)
│ ├── index.tsx # Dashboard home
│ ├── docks/
│ ├── projects/
│ └── infrastructure/
├── router.tsx # Router setup
└── components/ # React components
Why Convex:
- Real-time subscriptions (live sync updates)
- TypeScript-first (generated types)
- Built-in auth integration
- Serverless (no infra management)
- Scheduler (for periodic sync jobs)
Schema = Source of Truth:
schema.tsdefines entire data model- Types auto-generated
- Queries/mutations type-safe
Why Clerk:
- Organizations built-in (multi-tenancy)
- MFA support
- Webhooks for user sync
- Session management
- Enterprise-ready
Integration:
// Clerk JWT → Convex identity
const identity = await ctx.auth.getUserIdentity()
// identity.subject = Clerk user IDTanStack Query v5:
- Server state caching
- Optimistic updates
- Background refetching
- Automatic cache invalidation
XState v5:
- Complex workflows (dock connection, sync flows)
- State machine visualization
- Predictable state transitions
shadcn/ui: Copy/paste components (ownership model) Tailwind 4: Utility-first CSS (v4 has new features)
Custom Registry (future):
npx stackdock add server-health-widget
# Copies from StackDock registry, not shadcnstackdock/
├── .cursorrules # AI assistant rules
├── package.json # Root package.json (npm workspaces)
├── package-lock.json # npm lockfile
├── turbo.json # Turborepo config (optional)
│
├── apps/
│ ├── web/ # Main TanStack Start app
│ │ ├── app/
│ │ │ ├── routes/ # File-based routing
│ │ │ ├── components/ # React components
│ │ │ └── lib/ # Client utilities
│ │ ├── public/ # Static assets
│ │ ├── package.json
│ │ └── app.config.ts # TanStack Start config
│ │
│ └── docs/ # Documentation site (future)
│ ├── pages/
│ └── package.json
│
├── packages/
│ ├── docks/ # Dock adapter registry (source code)
│ │ ├── gridpane/
│ │ │ ├── adapter.ts
│ │ │ ├── api.ts
│ │ │ ├── types.ts
│ │ │ ├── README.md
│ │ │ └── package.json
│ │ ├── vercel/
│ │ ├── digitalocean/
│ │ ├── registry.json # Registry manifest
│ │ └── README.md # Registry documentation
│ │
│ ├── ui/ # UI component registry (shadcn model)
│ │ ├── components/
│ │ │ ├── server-health-widget/
│ │ │ ├── deployment-timeline/
│ │ │ └── domain-status-card/
│ │ ├── registry.json # Component manifest
│ │ └── README.md # Registry documentation
│ │
│ ├── cli/ # CLI tool for registry management
│ │ ├── src/
│ │ │ └── index.ts # CLI entry point
│ │ ├── bin/ # Executable
│ │ ├── package.json
│ │ └── README.md # CLI documentation
│ │
│ └── shared/ # Shared utilities
│ ├── types/ # Shared TypeScript types
│ ├── utils/ # Shared functions
│ └── package.json
│
├── convex/ # Convex backend (shared across apps)
│ ├── schema.ts # Data model (source of truth)
│ ├── auth.config.ts # Clerk integration
│ ├── lib/
│ │ ├── rbac.ts # RBAC middleware
│ │ ├── encryption.ts # Encryption functions
│ │ └── audit.ts # Audit logging
│ ├── users.ts
│ ├── organizations.ts
│ ├── docks/
│ │ ├── mutations.ts
│ │ ├── queries.ts
│ │ ├── actions.ts
│ │ └── adapters/ # Runtime adapters (copied from packages/docks/)
│ │ ├── gridpane/
│ │ │ ├── adapter.ts
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ └── vercel/
│ ├── resources/
│ │ ├── servers.ts
│ │ ├── webServices.ts
│ │ └── domains.ts
│ └── projects/
│
├── docs/ # Architecture & guides
│ ├── ARCHITECTURE.md # This file
│ ├── CONTRIBUTING.md # Development workflow
│ ├── DOCK_ADAPTER_GUIDE.md # How to build adapters
│ ├── REGISTRY_GUIDE.md # How to publish to registry
│ ├── SECURITY.md # Security patterns
│ ├── RBAC.md # RBAC documentation
│ └── adapters/
│ ├── gridpane.md
│ ├── vercel.md
│ └── template.md # Adapter template
│
└── scripts/
├── generate-encryption-key.js
└── setup-dev.sh
Root package.json (excerpt):
{
"name": "stackdock",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "npm run dev --workspace=apps/web",
"dev:convex": "npx convex dev",
"build": "npm run build --workspaces",
"lint": "npm run lint --workspaces",
"type-check": "npm run type-check --workspaces",
"test": "npm run test --workspaces",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\""
},
"packageManager": "npm@10.0.0"
}Workspace installs are handled via npm install <pkg> --workspace apps/web (or another workspace path), keeping the repo on npm end-to-end.
StackDock follows a strategic development priority: Convex/Translation Layer → TanStack Tables → XState State Machines.
Why? The universal schema is the foundation. Validate it across multiple providers before optimizing frontend components.
See: DEVELOPMENT_PRIORITY.md for the complete strategy, validation process, and roadmap.
Current Phase: Phase 1 (Convex/Translation Layer) - Adding providers to validate universal schema
- Read DEVELOPMENT_PRIORITY.md: Development strategy and roadmap
- Read CONTRIBUTING.md: Development workflow
- Read DOCK_ADAPTER_GUIDE.md: Build your first adapter
- Read SECURITY.md: Security patterns
- Read RBAC.md: Permission system details
Remember: This is infrastructure's WordPress moment. Every decision matters.