Deno Deploy
Deploy a Remix 3 app to Deno Deploy, the new platform at console.deno.com, which runs full Deno 2 apps with CDN caching and built-in logs, traces, and metrics. No platform plugin is involved: the Vite build runs in your CI, and Deploy runs the uploaded fetch handler under real Deno.
Configuration
remix() needs no Deploy-specific options. The defaults apply. deno.json and main.ts adapt the project to Deno:
{
"nodeModulesDir": "auto",
"tasks": {
"build": "vite build",
},
"deploy": {
// Uploads respect .gitignore; un-exclude the built output so the
// deploy tarball ships dist/.
"exclude": ["!dist"],
},
}Deploy's Dynamic Entrypoint is executed the way deno run executes a file, so it must start a server. Wrap the built fetch handler in three lines:
import server from "./dist/ssr/index.js";
Deno.serve({ port: Number(Deno.env.get("PORT") ?? 8000) }, request => server.fetch(request));Static assets keep flowing through the staticFiles("./dist/client") middleware in your router, same as every other server target.
The app's build configuration (dashboard Edit build config, or CLI flags):
| Setting | Value |
|---|---|
| Framework preset | No Preset |
| Install command | deno install --allow-scripts |
| Build command | (empty: the Vite build runs before deploying) |
| Runtime configuration | Dynamic |
| Dynamic Entrypoint | main.ts |
The Vite build runs before deno deploy, and the deploy.exclude entry above un-excludes the gitignored dist/ from the upload, so the tarball already contains the fingerprinted assets the running app serves. The install command only restores node_modules for the runtime. Leave the build command empty so Deploy doesn't build a second time.
Local development
vp dev # dev server through Pitlane's Vite plugin
vp build # production build
vp preview # serve the production buildTo exercise the production bundle under Deno itself before deploying:
deno serve --port 3000 dist/ssr/index.jsDeploy with the CLI
The deno deploy command comes with the Deno CLI. Build first, then deploy. The CLI tarballs the directory (respecting .gitignore, so the deploy.exclude un-exclusion above) and uploads it:
vp build
deno deploy --app my-remix-app --prod(The first bare deno deploy run authenticates and prompts for an app name.)
Create the app non-interactively (flags switch the wizard off):
Run it after vp build: --source local uploads this directory and publishes a first revision straight away.
deno deploy create \
--org my-org \
--app my-remix-app \
--source local \
--do-not-use-detected-build-config \
--install-command "deno install --allow-scripts" \
--runtime-mode dynamic \
--entrypoint main.ts \
--region globalWithout --do-not-use-detected-build-config, a build configuration Deploy detects in the directory takes precedence over the flags. --region also takes us or eu.
Useful companions:
deno deploy logs # stream runtime logs
deno deploy env load .env # push env vars; secret-looking keys are auto-marked secretDeploy with GitHub Actions
Create an access token at console.deno.com/account/access-tokens and store it as the DENO_DEPLOY_TOKEN repository secret. The workflow builds the Vite app (producing the fingerprinted assets in dist/) and then uploads it:
name: Deploy
on:
push:
branches: [main]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1.21.1
with:
cache: true
- run: vp install --frozen-lockfile
- run: vp build
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno deploy --app my-remix-app --prod
env:
DENO_DEPLOY_TOKEN: ${{ secrets.DENO_DEPLOY_TOKEN }}Environment variables
Set them per context (Production for production domains, Development for preview/branch URLs) in the dashboard's environment-variables drawer, or push a local file with deno deploy env load .env. They reach the app through Deno.env.get() (and process.env under Node compatibility).
Client-only apps
A client-only Remix 3 app runs remix() in SPA mode: server: false builds no server environment, so there is no wrapper entrypoint to write. The plugin's remaining job is component HMR, so it is worth keeping in a static build. Deno Deploy hosts static sites first-class, including a Single Page App mode that serves index.html for unmatched paths.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Remix App</title>
<script type="module" src="/app/main.tsx"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>import { createRoot, on, type Handle } from "remix/component";
function App(handle: Handle) {
let count = 0;
return () => (
<button
mix={[
on("click", () => {
count++;
handle.update();
}),
]}
>
Count: {count}
</button>
);
}
createRoot(document.getElementById("app")!).render(<App />);{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "remix/component" } }import { remix } from "pitlane/vite-plugin-remix";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix({ server: false })],
});pitlane/vite-plugin-remix comes from the pitlane package. The deno-deploy template maps the scoped packages in its deno.jsonc, such as "@pitlane/vite-plugin-remix": "npm:@pitlane/vite-plugin-remix@…", so add pitlane before switching the import. Run deno add npm:pitlane, which records a "pitlane": "npm:pitlane@…" entry in the same imports map. Deno resolves pitlane/vite-plugin-remix and the other subpaths through that entry. The Pitlane package guide lists the remix and vite versions it requires.
Configure the app as Static with directory dist and Single Page App mode on, either in the dashboard or in one non-interactive command:
deno deploy create \
--org my-org \
--app my-remix-spa \
--source local \
--do-not-use-detected-build-config \
--runtime-mode static \
--static-dir dist \
--single-page-app \
--region globalDeploys are unchanged: deno deploy --prod from the CLI, or the same GitHub Actions workflow above.