Technical guide
25

Technical guide · Published Sep 10, 2025

Building a Modern Full-Stack Web Application - Part 2

How I deployed the React Router application with Fly.io, Cloudflare, Neon, Docker, and migration history I could roll back.

A field-guide drawing of a canvas ridge tent
In this article3 sections

The application stack from Part 1 only became useful once I could deploy it repeatedly without losing database history. I used Fly.io for the application, Cloudflare at the edge, Neon for PostgreSQL, and packaged the application as a Docker image.

Hosting & Deployment

Infrastructure

Fly.io hosts the application. It scales to zero when idle and runs database migrations during deployment.

Cloudflare handles CDN, DDoS protection, and SSL.

Neon hosts PostgreSQL with serverless auto-scaling, database branching for dev/staging, point-in-time recovery, and connection pooling.

Docker Multi-Stage Builds

The application uses a multi-stage Docker build:

FROM node:24-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM base AS build
COPY . .
RUN npm run build

FROM base AS runtime
COPY --from=build /app/build ./build
COPY --from=deps /app/node_modules ./node_modules
EXPOSE 3000
CMD ["npm", "start"]

Development Workflow

# Development
npm run dev        # Start development server
npm run test       # Run tests
npm run typecheck  # Type checking

# Database
npm run db:studio  # Visual database management
npm run db:seed    # Populate with sample data
npm run db:migrate # Apply schema changes

Checks I kept in the loop

The project uses ESLint with React and TypeScript rules, Prettier, strict TypeScript with noUncheckedIndexedAccess, Husky hooks, Vitest, and Playwright. I wanted the same checks available locally and on every push.

Database Development with Drizzle Kit

Schema changes follow a structured approach:

# 1. Edit app/db/schema.ts
# 2. Generate migration
npm run db:generate

# 3. Apply migration
npm run db:migrate

# Development workflow
npm run db:push   # Quick schema sync (dev only)
npm run db:studio # Visual database UI
npm run db:reset  # Clean database reset

What worked in production

Vite delivers sub-second HMR, tree shaking, route-based code splitting, and image compression.

Drizzle ORM caches query plans with prepared statements and pools connections. Type-safe migrations prevent runtime errors.

Catalyst UI gave us a useful component base. Fly.io kept the deployment configuration small enough to understand.

Keep migration history in production Use db:push for disposable development state. Production changes need db:generate and db:migrate so the schema has reviewable history and a recovery path.

The deployment pipeline runs tests and type checks on every push, then runs migrations during deployment and rolls back on failure. I would pick this stack again, with one rule I would not relax: development can use db:push, but production changes need migration files.

One quick signal

Did this earn your time?

What was missing?

Thanks. That gives me something concrete to check.