Writing Technical guide

Web Development · 3 min read

Building a Modern Full-Stack Web Application - Part 2

How I deployed the React Router application with Fly.io, Cloudflare, Neon, Docker, and reviewable database migration history.

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 --omit=dev

FROM base AS build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM base AS runtime
COPY --from=build /app/package.json ./package.json
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 kept HMR quick in this project and handled bundling. React Router supplied route splitting. Image compression needs its own tooling; it is not a default Vite feature.

Prepared statements can reuse PostgreSQL query plans, and the driver manages pooled connections. Migration files give schema changes reviewable history; they do not guarantee safe changes or prevent every runtime error.

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. Fly.io’s release command stops a deploy if that command fails; it does not undo database changes already committed. Application rollback and database recovery need separate plans. I would pick this stack again, with one rule I would not relax: development can use db:push, but production changes need migration files.

Was this useful?

What was missing?

Thanks for the feedback.