Deployment
Deploying Remix applications to various platforms including Vercel, Fly.io, Cloudflare, and Node.js servers
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 linesDeployment — 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:
| Platform | Adapter 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 thenodejs_compatflag is enabled. Check your adapter's documentation for available APIs before using platform-specific code. -
Hardcoding
localhostin environment variables. Development URLs that referencelocalhostwill 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/expresswhen 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/, andpackage.json. - Set
NODE_ENV=productionin all deployment environments for optimal performance. - Serve
build/client/assets/withimmutableand longmax-ageheaders since filenames contain content hashes. - Add a
/healthcheckroute that verifies database connectivity for load balancer health checks. - Use platform-specific secrets management (Fly secrets, Vercel env vars, Cloudflare secrets) rather than
.envfiles in production. - Enable gzip or brotli compression at the reverse proxy or CDN level.
- Pin your Node.js version in
.nvmrcorpackage.jsonenginesto avoid drift between local and production.
Common Pitfalls
- Deploying without building first —
remix vite:buildmust 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 thenodejs_compatflag. - Not setting
process.env.NODE_ENVto"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
localhostURLs in environment variables — use relative paths or configure the origin from an environment variable. - Ignoring the adapter mismatch — using
@remix-run/expresson Cloudflare or vice versa causes runtime errors. The adapter must match the deployment platform.
Install this skill directly: skilldb add remix-skills
Related Skills
Actions
Server-side form mutations with action functions, progressive enhancement, and optimistic UI in Remix
Authentication
Authentication patterns in Remix using sessions, cookies, JWT, OAuth flows, and route protection
Error Handling
Error boundaries, catch boundaries, and structured error handling strategies in Remix applications
Loaders
Server-side data loading with loader functions, useLoaderData, and type-safe data fetching in Remix
Routing
Nested routes, dynamic segments, outlets, and file-based routing conventions in Remix
Streaming
Streaming responses and deferred data loading with defer, Await, and Suspense in Remix