// own product

Hipotle

A daily diagnosis game with Brazilian cases, and how I redesigned its infrastructure after it outgrew the first setup.

Role
Founder and engineer
Status
In production

Next.js · TypeScript · Azure Container Apps · Azure Front Door · Bicep · Postgres (Neon) · Drizzle · Key Vault · Application Insights

total visits
200,000+total visits
visits last 24h
1,299visits last 24h
medical specialties
35medical specialties

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 serverlessAzure Container Apps
Short-lived function executionsLong-running application processes
Connection lifecycle tied to invocationsReusable database connections
Serverless scalingConfigurable replica scaling
Less control over the runtimeControl over CPU, memory and concurrency

Architecture

Azure (Bicep)
AI content pipeline

SERVICE

Azure Front Door

Explicit cache rules

SERVICEREPLICAS

Next.js app

Azure Container Apps: VNet, autoscaling

  • Next.js
  • TypeScript
  • SSR + SEO
  • PWA

SERVICE

Admin platform

RBAC and audit log

EXTERNAL

Key Vault

Managed Identity access

EXTERNAL

Application Insights

SLO alerting

SERVICE

Generate

35 specialties

SERVICE

Validate

Semantic duplicate detection

SERVICE

Import

Into the case library

PEOPLE

Players

PEOPLE

Case reviewers

Medical students

DATASTORE

Neon Postgres

Drizzle ORM

Hipotle architectureDrag nodes to explore
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.

← Selected work