Skip to content
Pitlane

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.

Start from the template

npx giget github:pitlane-tools/templates/deno-deploy my-app scaffolds a working guest book app wired for this guide — see pitlane-tools/templates.

New Deploy only

This guide targets the current Deno Deploy. Deploy Classic (dash.deno.com) shut down on July 20, 2026 — if you're coming from it, see the migration guide.

Configuration

remix() needs no Deploy-specific options — the defaults apply. Two small files adapt the project to Deno:

jsonc
// deno.json
{
    "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:

ts
// main.ts
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):

SettingValue
Framework presetNo Preset
Install commanddeno install --allow-scripts
Build command(empty — the Vite build runs before deploying)
Runtime configurationDynamic
Dynamic Entrypointmain.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

sh
vp dev      # dev server through @pitlane/dev
vp build    # production build
vp preview  # serve the production build

To exercise the production bundle under Deno itself before deploying:

sh
deno serve --port 3000 dist/ssr/index.js

Deploy with the CLI

The deno deploy command ships with the Deno CLI. Build first, then deploy — the CLI tarballs the directory (respecting .gitignore, hence the deploy.exclude un-exclusion above) and uploads it:

sh
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):

sh
deno deploy create \
    --org my-org \
    --app my-remix-app \
    --install-command "deno install --allow-scripts" \
    --entrypoint main.ts

Useful companions:

sh
deno deploy logs                 # stream runtime logs
deno deploy env load .env       # push env vars; secret-looking keys are auto-marked secret

Deploy 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:

yaml
# .github/workflows/deploy.yml
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
              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 skips @pitlane/dev entirely — with no SSR and no clientEntry() boundaries there is nothing to transform, so no Remix- or Pitlane-specific Vite settings are needed. Deno Deploy hosts static sites first-class, including a Single Page App mode that serves index.html for unmatched paths — no wrapper entrypoint, no fallback hacks.

html
<!-- index.html -->
<!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>
tsx
// app/main.tsx
import { createRoot, on, type Handle } from "remix/ui";

function App(handle: Handle) {
    let count = 0;

    return () => (
        <button
            mix={[
                on("click", () => {
                    count++;
                    handle.update();
                }),
            ]}
        >
            Count: {count}
        </button>
    );
}

createRoot(document.getElementById("app")!).render(<App />);
jsonc
// tsconfig.json
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "remix/ui" } }

Configure the app as Static with directory dist and Single Page App mode on — in the dashboard, or in one non-interactive command:

sh
deno deploy create \
    --org my-org \
    --app my-remix-spa \
    --runtime-mode static \
    --static-dir dist \
    --single-page-app

Deploys are unchanged: deno deploy --prod from the CLI, or the same GitHub Actions workflow above.