Overview
Hipotle is a daily diagnosis game with Brazilian cases, for doctors and medical students. It started as a vibe coded project and grew past what that first setup could handle, so I redesigned the infrastructure the way I would at work. This page covers what went wrong on Vercel, how it runs today and the decisions behind it.
Initial Design
Vercel was a good fit initially. Its serverless model made deployment simple and handled scaling without requiring me to manage any infrastructure.
As the application evolved, I started noticing performance issues, particularly during periods of higher concurrent traffic. The problem wasn't necessarily request capacity itself, but the overhead introduced by the serverless execution model.
Each API request could trigger a separate function invocation, with database communication established independently. Combined with the Neon HTTP driver, which introduced additional network round trips for individual queries, this added latency to database-heavy endpoints.
Moving to Container Apps
I moved the app to Azure Container Apps, which gives the application a persistent runtime instead of short-lived executions. That let me:
- keep warm instances instead of initializing a new execution environment again and again
- reuse database connections through connection pooling
- set a minimum number of replicas, so there are no cold-start delays
- control concurrency, scaling and resource allocation
The pooling is the part that matters most. The pool is created once per container process and shared by every request, instead of connecting again on each invocation:
import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
// Simplified sketch: one pool per container process, shared by every request
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.DB_POOL_MAX),
});
export const db = drizzle(pool);
The result was more consistent response times under concurrent traffic, and a backend I can keep tuning as the app grows. The difference comes down to this:
| Vercel serverless | Azure Container Apps |
|---|---|
| Short-lived function executions | Long-running application processes |
| Connection lifecycle tied to invocations | Reusable database connections |
| Serverless scaling | Configurable replica scaling |
| Less control over the runtime | Control over CPU, memory and concurrency |
Architecture
Dependencies (text)
- players -> frontdoor [HTTPS]
- frontdoor -> app [routes and caches]
- app -> db [reads cases]
- app -> vault [secrets]
- app -> insights [telemetry]
- reviewers -> admin [review and manage]
- admin -> db [writes, audit log]
- generate -> validate [candidate cases]
- validate -> import [accepted cases]
- import -> db [imports]
Compute and edge
The app is Next.js and TypeScript, packaged as a container and run on Azure Container Apps. It sits inside a virtual network, keeps a minimum number of replicas warm and scales out with load, which is why the diagram shows it as a stack of replicas rather than a single box.
Azure Front Door sits in front, on the Standard tier, with explicit caching rules. I decide what gets cached at the edge instead of leaving it to defaults.
Data and secrets
The cases live in Postgres on Neon, accessed through Drizzle ORM. The database is the one piece that did not move when the rest did. The data model is small: matches, users and their settings, badges and points, and a ranking computed with a ROW_NUMBER window over grouped points.
Secrets are in Key Vault, and the app reads them through a Managed Identity. That means it has no stored credential of its own to fetch them with.
Observability
Application Insights collects telemetry from the app and alerts on the SLOs I defined. I wanted the monitoring to be mine, not something that came bundled with the host.
Infrastructure as code
The whole environment is modular Bicep. Staging and production are built from the same modules, so they are identical, and every container image is versioned per commit. Any commit maps to exactly what is running. I write architecture decisions down as ADRs.
This is a simplified sketch of how the app, its identity and its secrets fit together in Bicep. Every value comes from a parameter, which is what keeps staging and production identical:
resource app 'Microsoft.App/containerApps@2024-03-01' = {
name: appName
location: location
identity: { type: 'SystemAssigned' }
properties: {
managedEnvironmentId: environmentId // the VNet-integrated environment
configuration: {
ingress: { external: true, targetPort: targetPort }
secrets: [
{ name: 'database-url', keyVaultUrl: databaseUrlSecretUri, identity: 'system' }
]
}
template: {
containers: [
{
name: 'web'
image: image // versioned per commit
env: [{ name: 'DATABASE_URL', secretRef: 'database-url' }]
}
]
scale: {
minReplicas: minReplicas // warm instances, no cold starts
maxReplicas: maxReplicas
rules: [
{ name: 'http', http: { metadata: { concurrentRequests: string(concurrentRequests) } } }
]
}
}
}
}
The content pipeline and the admin side
Cases come from an AI pipeline with three stages: generate, validate and import. It covers 35 medical specialties, and validation checks for duplicates both by similarity and by meaning, which keeps the case library clean. Four medical students review the cases and look after the community.
There is an admin platform with role-based access and audit logging. The game itself is an installable PWA with a server-rendered layer, so search engines can read it.
Result
Response times are now consistent under concurrent traffic, peak days included. Hipotle has passed 223k visits, with around 2k a day in the last month. I'm still the only engineer, and Claude still writes a lot of the code.