launchthat
Portal V4: Bare Metal, Kubernetes, and True Multi-Tenancy
We moved off Vercel onto a 128GB bare-metal server. Each client now gets their own frontend containers, their own Convex backend instance, and their own database backups — orchestrated with Kubernetes and Helm. Here is how we got here and what we learned about owning infrastructure.
This is Part 4 of a four-part series tracing the Portal platform from WordPress to Kubernetes. Part 3 covered the Convex migration. This article covers the move to self-hosted infrastructure — the most ambitious (and ongoing) phase of Portal's evolution.
The server that started it all
I did not buy a bare-metal server for Portal. I bought it for AI.
A side project — LaunchThatBot — needed local GPU compute for running language models. Cloud GPU instances were expensive and unpredictable. For the cost of a few months of cloud GPU rentals, I could lease a dedicated server in a data center. 128GB RAM, NVMe storage, direct network connectivity. The math was straightforward.
Once the server was racked and running, I realized something: this machine had far more capacity than my AI workloads needed. It was sitting 70% idle most of the time. Meanwhile, I was paying Vercel to host Portal's frontend and wishing I could run more microfrontends without the per-project pricing.
The infrastructure I needed for Portal V4 was already humming in a data center. I just had to use it.
Why leave Vercel
Vercel is an excellent platform. For V2 and V3, it was the right choice — preview deployments, zero-config SSL, edge functions, and a deployment experience that got out of my way. But three forces pushed me toward self-hosting:
Microfrontend economics. Portal's plugin architecture naturally maps to microfrontends — each plugin feature as its own deployable frontend app. The page builder experiment in V3 proved this worked. But at $20+ per Vercel project, 36 plugin microfrontends would cost $720/month in hosting alone. On my own server, the incremental cost of another container is measured in megabytes of RAM.
True tenant isolation. V3 isolated tenant data through workspaceId scoping in a shared Convex instance. That is fine for most use cases, but some clients need physical isolation — separate database instances, separate backup schedules, separate failure domains. Running per-tenant Convex backends requires infrastructure I control.
Convex replaced the API layer. By the end of V3, virtually every Next.js API route had been migrated to Convex queries, mutations, and actions. The serverless function runtime that Vercel provides — and charges for — was sitting idle. The frontend was a React app that talked directly to Convex. It did not need a Node.js server, edge functions, or API routes. Paying for a full-stack hosting platform to serve what had become a static frontend was like renting a warehouse to store a backpack. This realization was also what made the switch from Next.js to Vite practical. With Convex owning the entire backend — data, auth, real-time, file storage, scheduled jobs — the frontend's only job was rendering UI. I no longer needed SSR, API routes, or middleware. I could focus purely on the frontend experience, and Vite was purpose-built for exactly that: fast builds, minimal config, and no server runtime to manage.
Operational ownership. Every layer you do not control is a layer you cannot debug, optimize, or customize. Vercel's build system, edge network, and serverless runtime are black boxes. When something goes wrong, you are reading status pages instead of log files. Owning the stack from bare metal to DNS means I can diagnose and fix issues at any layer.
The V4 architecture
The new stack replaces Vercel with self-hosted infrastructure while keeping Convex as the backend platform:
Cloudflare DNS
│
▼
Caddy (Reverse Proxy + Auto TLS)
│
├── Portal Control Plane (Vite app)
│ ├── User signup / org creation
│ ├── Tenant provisioning API
│ └── Admin dashboard
│
├── Tenant A
│ ├── Vite Microfrontend: LMS
│ ├── Vite Microfrontend: CRM
│ ├── Vite Microfrontend: Support
│ └── Convex Backend Instance A
│
├── Tenant B
│ ├── Vite Microfrontend: LMS
│ ├── Vite Microfrontend: E-commerce
│ └── Convex Backend Instance B
│
└── Tenant C
├── Vite Microfrontend: LMS
├── Vite Microfrontend: CRM
├── Vite Microfrontend: Disclaimers
└── Convex Backend Instance C
All orchestrated by Kubernetes + Helm
Running on bare-metal server (128GB RAM)
Each tenant gets:
- Their own set of microfrontend containers (only the plugins they have activated)
- Their own Convex backend instance (deployed from a versioned git template)
- Their own database backups on their own schedule
- Their own subdomain or custom domain
From Next.js to Vite
The move from Next.js to Vite was driven by the microfrontend strategy. Next.js is a full-stack framework — server-side rendering, API routes, middleware. Each microfrontend does not need all of that. Microfrontends serve a focused UI concern, and Convex handles the backend. What they need is fast builds, small bundles, and simple containerization.
Vite delivers exactly that. Build times dropped from minutes to seconds. Docker images are tiny — a Vite production build is static files behind a lightweight web server. No Node.js runtime in the container, no serverless function cold starts, no hydration complexity.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM caddy:alpine
COPY --from=builder /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
Each plugin microfrontend builds into its own Docker image. The LMS plugin is one image. The CRM plugin is another. The support chat is another. They share a common shell that handles authentication and navigation, but each plugin's UI is independently deployable.
Kubernetes orchestration
Kubernetes manages the container lifecycle. When a new tenant signs up and activates plugins, the control plane triggers a deployment:
- Helm chart templating — a parameterized chart defines the resources for one tenant (namespace, deployments, services, ingress rules)
- Plugin selection — only the activated plugin containers are included in the tenant's deployment
- Convex provisioning — a new Convex backend instance is deployed from a git template with the correct schema version
- DNS configuration — Cloudflare API creates the subdomain record, Caddy picks up the new route
- Health checks — Kubernetes verifies all containers are running before the tenant is marked as active
# Simplified Helm values for one tenant
tenant:
id: "acme-corp"
domain: "acme.launchthat.app"
plugins:
- lms
- crm
- support
- ecommerce
convex:
deploymentName: "acme-corp-production"
schemaVersion: "2.4.0"
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
The Helm chart is the single source of truth for a tenant's configuration. Adding a plugin is a values change and a helm upgrade. Removing a plugin scales down the corresponding deployment without affecting the others.
Per-tenant backend isolation
This is the architectural shift that V4 exists for. In V3, every tenant shared one Convex instance with data separated by workspaceId indexes. In V4, each tenant gets their own Convex backend instance.
The backend template lives in a git repository. It defines the full schema — every table, every index, every component. When a new tenant is provisioned, the control plane:
- Deploys a new Convex instance from the template
- Runs the seed script to set up default data (roles, settings, initial admin user)
- Stores the deployment credentials in the tenant's Kubernetes secrets
- Configures the frontend microfrontends to connect to this specific instance
Schema version control is critical. When I update the backend template — adding a field, creating a new index, introducing a new table — every tenant's instance needs to receive the update. The control plane tracks which schema version each tenant is running and orchestrates rolling upgrades across all instances.
interface TenantDeployment {
tenantId: string;
convexDeploymentName: string;
schemaVersion: string;
activePlugins: string[];
provisionedAt: number;
lastUpgradedAt: number;
}
This gives clients what they have been asking for: true isolation. Tenant A's database failure does not affect Tenant B. Tenant A's backup schedule is independent of Tenant B's. If a client needs their data in a specific geographic region, I can provision their instance accordingly.
The infrastructure stack
From bottom to top, the full stack I now own:
| Layer | Technology | Purpose |
|---|---|---|
| Hardware | Bare-metal server (128GB RAM, NVMe) | Compute and storage |
| OS | Ubuntu Server | Base operating system |
| Container runtime | containerd | Container execution |
| Orchestration | Kubernetes (k3s) | Container scheduling and lifecycle |
| Package management | Helm | Templated Kubernetes deployments |
| Reverse proxy | Caddy | TLS termination, routing, auto-HTTPS |
| DNS | Cloudflare | Domain management, DDoS protection |
| Backend | Convex (per-tenant instances) | Database, API, real-time |
| Frontend | Vite (containerized microfrontends) | User interfaces |
| CI/CD | GitHub Actions | Build, test, deploy pipeline |
| Monitoring | Prometheus + Grafana | Metrics and alerting |
I chose k3s over full Kubernetes for a single-server deployment. It provides the Kubernetes API and scheduling without the overhead of etcd clustering and multi-node control plane components. If the platform grows to need multiple servers, migrating from k3s to a multi-node cluster is straightforward.
Caddy handles TLS automatically via Let's Encrypt. Every tenant subdomain gets HTTPS without manual certificate management. Caddy's configuration is dynamically generated from the Kubernetes ingress state, so new tenants get SSL the moment their containers are healthy.
AI-assisted development
V4 coincides with a major change in how I build software. Through LaunchThatBot, I have access to AI orchestration — multiple coding agents that can work on different parts of the platform simultaneously.
This matters for V4's architecture because the microfrontend split makes parallel development practical. One agent works on the CRM plugin's frontend. Another works on the LMS plugin's quiz feature. A third updates the Helm chart for a new plugin deployment. They operate on different codebases, different containers, different deployment pipelines. The architectural boundaries that make the platform resilient for clients also make it parallelizable for development.
In V2, AI struggled because everything was interconnected — a single change could ripple across six files in three layers. In V4, each plugin is a bounded context. An AI agent working on the CRM plugin needs to understand CRM concepts and the Convex component interface. It does not need to understand the LMS, the page builder, or the Kubernetes deployment.
The cost story: full circle
Here is the cost comparison across all four versions:
| Category | V1 | V2 | V3 | V4 |
|---|---|---|---|---|
| Hosting / compute | $100 | $20 | $20 | ~$80 (bare metal lease) |
| Database | incl. | $0 | $25 | ~$25 per tenant |
| Third-party services | $270 | $230 | ~$0 | $0 |
| Platform fees | $0 | $0 | $0 | $0 |
| Total (3 tenants) | $370 | $250 | $45 | ~$155 |
V4 is more expensive than V3 — but the cost buys something V3 could not provide: true per-tenant isolation. Each client has their own backend instance, their own backups, their own failure domain. The bare metal lease is a fixed cost that scales with the number of tenants I can fit on the server, not a per-project fee that scales linearly.
At 10 tenants, the per-tenant cost drops to roughly $10-12/month for infrastructure. At 20 tenants, it drops further. The economics improve as the platform grows because the fixed infrastructure cost is amortized across more tenants. Vercel's per-project pricing works in the opposite direction — more projects means linearly more cost.
What I have learned across seven years
Standing at V4 and looking back at V1, the evolution makes sense in hindsight. Each version solved the problems of the previous one and introduced constraints that drove the next.
V1 taught me that the idea works. Real clients paid real money for a multi-tenant platform. WordPress was the right tool for validation.
V2 taught me to build. Replacing WordPress with custom code was the hardest thing I had done professionally. It turned me from a builder into a developer.
V3 taught me that the right backend changes everything. Convex did not just make the platform faster to develop — it changed what was possible. Real-time features, plugin isolation, and service consolidation all became tractable once the backend layer was right.
V4 is teaching me that infrastructure is power. Owning the full stack — from bare metal to DNS — is more work than using managed platforms. But it gives me control over the economics, the architecture, and the operational characteristics of the platform in ways that no managed service can match.
The through-line is not technology. It is ownership. Each version expanded the boundary of what I controlled. WordPress controlled the data model. Vercel controlled the deployment. Now I control the metal, the network, the orchestration, and the deployment pipeline. The platform's capabilities are limited by my skills and my hardware, not by the pricing page of a service I do not own.
What comes next
V4 is not finished. The architecture described in this article is where the platform is headed. Some pieces are production-ready. Others are in active development. The honest version is:
- Working now: Kubernetes cluster, Caddy reverse proxy, containerized Vite microfrontends, GitHub Actions CI/CD
- In progress: Automated per-tenant Convex provisioning, Helm chart templating for tenant lifecycle
- Planned: Self-service tenant signup with automatic provisioning, multi-region deployment, horizontal scaling across multiple servers
I am building this in public because the journey from WordPress to Kubernetes is not just a technical story — it is a story about what happens when you refuse to stop learning. Seven years ago I was a WordPress builder who could not write a database query. Today I am managing containers on bare metal and designing multi-tenant orchestration systems.
The technology changed at every stage. The drive to build something better stayed the same.
This concludes the Portal Evolution series. Start from the beginning with Part 1: The WordPress Era, or explore the Portal project page for the current technical architecture.
Want to see how this was built?
See the Portal projectWant to see how this was built?
Browse all posts