Skip to main content
Technology & EngineeringRemix273 lines

Deployment

Deploying Remix applications to various platforms including Vercel, Fly.io, Cloudflare, and Node.js servers

Quick Summary36 lines
You are an expert in deploying Remix applications to various platforms, including configuration, adapters, environment variables, build optimization, and production best practices.

## Key Points

- `build/server/` — The server bundle (entry point for your hosting platform).
- `build/client/` — Static assets (JS, CSS, images) to be served from a CDN or static file server.
- Use multi-stage Docker builds to keep the production image small — copy only `build/`, `node_modules/`, and `package.json`.
- Set `NODE_ENV=production` in all deployment environments for optimal performance.
- Serve `build/client/assets/` with `immutable` and long `max-age` headers since filenames contain content hashes.
- Add a `/healthcheck` route that verifies database connectivity for load balancer health checks.
- Use platform-specific secrets management (Fly secrets, Vercel env vars, Cloudflare secrets) rather than `.env` files in production.
- Enable gzip or brotli compression at the reverse proxy or CDN level.
- Pin your Node.js version in `.nvmrc` or `package.json` `engines` to avoid drift between local and production.
- Deploying without building first — `remix vite:build` must run before the server starts. CI pipelines should include the build step.
- Using Node.js APIs (like `fs`, `path`) when deploying to Cloudflare Workers — the Workers runtime does not have Node.js built-ins unless you enable the `nodejs_compat` flag.
- Not setting `process.env.NODE_ENV` to `"production"` — Remix includes extra development tooling that slows down production performance.

## Quick Example

```json
// vercel.json (usually not needed with the preset)
{
  "buildCommand": "remix vite:build",
  "outputDirectory": "build"
}
```

```toml
# wrangler.toml
name = "my-remix-app"
compatibility_date = "2024-11-01"
main = "build/server/index.js"
assets = { directory = "build/client" }
```
skilldb get remix-skills/DeploymentFull skill: 273 lines
Paste into your CLAUDE.md or agent config

Deployment — Remix

You are an expert in deploying Remix applications to various platforms, including configuration, adapters, environment variables, build optimization, and production best practices.

Overview

Remix compiles to a server handler and a set of static assets. The server handler can run on virtually any JavaScript runtime — Node.js, Deno, Cloudflare Workers, or edge runtimes. Remix provides official adapters for different platforms that translate the platform's request/response model into the Web Fetch API that Remix uses internally. Choosing the right adapter and configuring the build correctly is key to a smooth deployment.

Core Concepts

Build Output

Running remix vite:build (or remix build for the classic compiler) produces:

  • build/server/ — The server bundle (entry point for your hosting platform).
  • build/client/ — Static assets (JS, CSS, images) to be served from a CDN or static file server.

Adapters

Adapters bridge Remix's Fetch-based handler to the platform's native API:

PlatformAdapter Package
Node.js / Express@remix-run/express
Vercel@remix-run/vercel (or Vite plugin)
Cloudflare Workers@remix-run/cloudflare
Cloudflare Pages@remix-run/cloudflare-pages
Fly.io@remix-run/node + Dockerfile
Deno@remix-run/deno
Architect (AWS)@remix-run/architect

Implementation Patterns

Node.js with Express

// server.ts
import { createRequestHandler } from "@remix-run/express";
import express from "express";
import path from "node:path";

const app = express();

// Serve static assets with aggressive caching
app.use(
  "/assets",
  express.static(path.join(process.cwd(), "build/client/assets"), {
    immutable: true,
    maxAge: "1y",
  })
);

// Serve other static files with short cache
app.use(express.static(path.join(process.cwd(), "build/client"), { maxAge: "1h" }));

// Remix handler
app.all(
  "*",
  createRequestHandler({
    build: await import("./build/server/index.js"),
    mode: process.env.NODE_ENV,
  })
);

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

Dockerfile for Fly.io / Container Platforms

FROM node:20-slim AS base
WORKDIR /app

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

FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
RUN npm prune --production

FROM base AS production
ENV NODE_ENV=production
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
COPY --from=build /app/server.ts ./

EXPOSE 3000
CMD ["node", "--import", "tsx", "server.ts"]
# fly.toml
app = "my-remix-app"
primary_region = "iad"

[build]

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 1

[env]
  NODE_ENV = "production"

Vercel Deployment

With the Vite plugin, Vercel deployment works with zero additional config:

// vite.config.ts
import { vitePlugin as remix } from "@remix-run/dev";
import { vercelPreset } from "@vercel/remix/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    remix({
      presets: [vercelPreset()],
    }),
  ],
});
// vercel.json (usually not needed with the preset)
{
  "buildCommand": "remix vite:build",
  "outputDirectory": "build"
}

Cloudflare Workers / Pages

// vite.config.ts
import { vitePlugin as remix } from "@remix-run/dev";
import { cloudflareDevProxy } from "@remix-run/dev/vite/cloudflare";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    cloudflareDevProxy(),
    remix(),
  ],
});
# wrangler.toml
name = "my-remix-app"
compatibility_date = "2024-11-01"
main = "build/server/index.js"
assets = { directory = "build/client" }

Environment Variables

// Server-side (loaders/actions) — access directly
export async function loader() {
  const apiKey = process.env.API_KEY; // available
  return json({ data: await fetchData(apiKey) });
}

// Client-side — must be prefixed and explicitly passed
// In the root loader:
export async function loader() {
  return json({
    ENV: {
      PUBLIC_API_URL: process.env.PUBLIC_API_URL,
    },
  });
}

// In root.tsx component:
export default function Root() {
  const { ENV } = useLoaderData<typeof loader>();
  return (
    <html>
      <head><Meta /><Links /></head>
      <body>
        <Outlet />
        <script
          dangerouslySetInnerHTML={{
            __html: `window.ENV = ${JSON.stringify(ENV)}`,
          }}
        />
        <Scripts />
      </body>
    </html>
  );
}

Health Check Endpoint

// app/routes/healthcheck.tsx
export async function loader() {
  try {
    await db.$queryRaw`SELECT 1`;
    return new Response("OK", { status: 200 });
  } catch {
    return new Response("Service Unavailable", { status: 503 });
  }
}

Static Asset Caching Strategy

Remix fingerprints files in build/client/assets/ with content hashes, so they can be cached forever. Other files in build/client/ (like favicon.ico) should have shorter cache durations:

// For CDN or reverse proxy config:
// /assets/* -> Cache-Control: public, max-age=31536000, immutable
// /*        -> Cache-Control: public, max-age=3600

Core Philosophy

Remix treats deployment as a build-time concern, not a runtime discovery. The adapter you choose determines which platform APIs are available, how the server handler is invoked, and what constraints your code must respect. This explicit coupling between the adapter and the target environment forces you to make the deployment decision early, which prevents the common problem of writing code that works in development but fails on the production platform.

The build output is deliberately simple: a server bundle and a directory of static assets. The server bundle is a single entry point that receives requests and returns responses using the Web Fetch API. The static assets are fingerprinted with content hashes for immutable caching. This simplicity means you can deploy Remix to virtually any JavaScript runtime, from a Docker container to an edge function, by writing a thin adapter that translates the platform's request format to Fetch.

Environment management in Remix follows the principle that server-side code has full access to secrets while client-side code has none unless explicitly passed. There is no PUBLIC_ prefix convention baked into Remix; instead, you explicitly choose which environment variables to expose by returning them from a loader and injecting them into a window.ENV object. This makes secret leakage a deliberate act rather than an accidental misconfiguration.

Anti-Patterns

  • Deploying without running the build step. The Remix server requires compiled output from remix vite:build. Starting the server against source files causes immediate failures. Ensure your CI/CD pipeline includes the build step before deployment.

  • Using Node.js-specific APIs when deploying to Cloudflare Workers. The Workers runtime does not have fs, path, or other Node built-ins unless the nodejs_compat flag is enabled. Check your adapter's documentation for available APIs before using platform-specific code.

  • Hardcoding localhost in environment variables. Development URLs that reference localhost will break in production. Use relative paths or configure the origin from an environment variable that differs per deployment environment.

  • Not serving the build/client/ directory. If static assets are not served, the application loads without JavaScript, CSS, and images. Client-side navigation, styling, and interactivity all break silently.

  • Mixing adapter packages across platforms. Using @remix-run/express when deploying to Cloudflare or vice versa causes runtime errors because the adapter assumes a specific request/response model. The adapter must match the deployment platform exactly.

Best Practices

  • Use multi-stage Docker builds to keep the production image small — copy only build/, node_modules/, and package.json.
  • Set NODE_ENV=production in all deployment environments for optimal performance.
  • Serve build/client/assets/ with immutable and long max-age headers since filenames contain content hashes.
  • Add a /healthcheck route that verifies database connectivity for load balancer health checks.
  • Use platform-specific secrets management (Fly secrets, Vercel env vars, Cloudflare secrets) rather than .env files in production.
  • Enable gzip or brotli compression at the reverse proxy or CDN level.
  • Pin your Node.js version in .nvmrc or package.json engines to avoid drift between local and production.

Common Pitfalls

  • Deploying without building first — remix vite:build must run before the server starts. CI pipelines should include the build step.
  • Using Node.js APIs (like fs, path) when deploying to Cloudflare Workers — the Workers runtime does not have Node.js built-ins unless you enable the nodejs_compat flag.
  • Not setting process.env.NODE_ENV to "production" — Remix includes extra development tooling that slows down production performance.
  • Forgetting to serve the build/client/ directory — the app loads without JavaScript, breaking client-side navigation.
  • Hardcoding localhost URLs in environment variables — use relative paths or configure the origin from an environment variable.
  • Ignoring the adapter mismatch — using @remix-run/express on Cloudflare or vice versa causes runtime errors. The adapter must match the deployment platform.

Install this skill directly: skilldb add remix-skills

Get CLI access →