Back to Blog
Tutorial6 min read2026-07-11

How to Deploy Fresh (Deno) with PandaStack

Deploy a Fresh (Deno) app to production: ahead-of-time builds, a working Dockerfile, Postgres over DATABASE_URL, and going live with a git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Fresh is Deno's full-stack web framework: server-rendered by default, Preact islands for the interactive parts, and no node_modules directory anywhere in sight. It ships almost no JavaScript to the client unless you ask it to, which makes it one of the fastest frameworks to serve — and one of the cheapest to run, because the server process is a single Deno binary doing very little work per request.

Deploying it is straightforward once you understand two things: Fresh's ahead-of-time build step, and how Deno's permission model interacts with containers. Here's the whole path, end to end.

The production build

Fresh started life with no build step at all — it compiled islands on the fly, per request. That's fine in development and painful in production: every cold start had to spin up the bundler before serving traffic. Modern Fresh versions fix this with an ahead-of-time build:

deno task build

This pre-compiles your islands and assets into a _fresh/ snapshot directory. When you then start the server:

deno run -A main.ts

Fresh detects the snapshot and serves from it directly — no bundling at boot. If you skip the build step, the app still works, but startup is slower and the server does compilation work it shouldn't be doing. On any platform that scales to zero, that difference is your cold start time, so always build ahead of time.

Your deno.json is the source of truth for the exact task names — Fresh versions have shuffled entry points over time, so check what build, start, and preview map to in your project rather than assuming:

deno task

Two more production notes:

  • Commit deno.lock. It pins every remote dependency, which is the only way to get reproducible container builds when your imports are URLs.
  • Fresh listens on port 8000 by default (that's Deno.serve's default). You can keep that in a container — just make sure the port you expose matches. If your Fresh version supports passing server options through its config, you can also read PORT from the environment there.

Connecting Postgres

Deno supports npm specifiers, so the cleanest Postgres client is postgres (postgres.js), imported directly — no install step:

// lib/db.ts
import postgres from "npm:postgres@3";

export const sql = postgres(Deno.env.get("DATABASE_URL")!, {
  max: 5,
});

One connection string, one line. This matters more than it looks: on PandaStack, when you attach a managed PostgreSQL instance to your app, DATABASE_URL is injected into the container automatically. You never copy credentials between dashboards, and nothing database-related lives in your repo.

Using it from a Fresh route handler:

// routes/api/notes.ts
import { Handlers } from "$fresh/server.ts";
import { sql } from "../../lib/db.ts";

export const handler: Handlers = {
  async GET() {
    const notes = await sql`
      SELECT id, body, created_at
      FROM notes
      ORDER BY id DESC
      LIMIT 20
    `;
    return Response.json(notes);
  },
  async POST(req) {
    const { body } = await req.json();
    const [note] = await sql`
      INSERT INTO notes (body) VALUES (${body})
      RETURNING id, body
    `;
    return Response.json(note, { status: 201 });
  },
};

Note that postgres.js template literals are parameterized queries — the ${body} interpolation above is safe, not string concatenation.

Migrations

Fresh has no ORM and no migration system, which I consider a feature — you pick your own. For small projects, a plain script is enough:

// scripts/migrate.ts
import postgres from "npm:postgres@3";

const sql = postgres(Deno.env.get("DATABASE_URL")!, { max: 1 });

await sql`
  CREATE TABLE IF NOT EXISTS notes (
    id serial PRIMARY KEY,
    body text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
  )
`;

console.log("migrations applied");
await sql.end();

Run it with exactly the permissions it needs:

deno run --allow-env --allow-net scripts/migrate.ts

For anything beyond a handful of tables, use a real migration tool (dbmate and Atlas both work fine against a plain DATABASE_URL). Whatever you choose, run migrations as a deliberate step before new code takes traffic — not lazily inside a request handler.

The Dockerfile

Deno isn't one of the buildpack-detected runtimes on most platforms, so the reliable route is a Dockerfile. PandaStack builds any Dockerfile it finds in your repo, so this is all the deployment config you need:

FROM denoland/deno:alpine

WORKDIR /app

COPY . .

# Warm the dependency cache into the image layer
RUN deno cache main.ts

# Ahead-of-time build: islands are pre-compiled, boot is instant
RUN deno task build

EXPOSE 8000
USER deno

CMD ["run", "-A", "main.ts"]

A few things worth calling out:

  • deno cache main.ts downloads and compiles every dependency at build time, so the running container never fetches anything over the network at boot. With deno.lock committed, this is fully reproducible.
  • deno task build runs inside the image, so the _fresh/ snapshot ships with the container. Don't gitignore-and-forget it into the build — generate it in the Dockerfile where it belongs.
  • USER deno drops root before the app starts. The build steps run as root (they need to write files); the server doesn't need to write anything, so it shouldn't be able to.
  • -A grants all permissions inside the container. You can tighten this to --allow-net --allow-env --allow-read if you want Deno's sandbox as an extra layer, but inside an isolated container the practical benefit is small. Start with -A, tighten later if it bothers you.

Deploying on PandaStack

With the Dockerfile in place, the deploy is a git push:

  1. 1Create the database first. Provision a managed PostgreSQL instance (14.x or 16.x are both available) from the dashboard. It runs on Kubernetes via KubeBlocks with daily scheduled backups — 7 days of retention on the free tier, 15 on Pro, 30 on Premium.
  2. 2Connect the repo as a container app. PandaStack finds the Dockerfile and builds it with rootless BuildKit in an ephemeral Kubernetes job pod — there's no shared Docker daemon involved, and you watch the build logs stream live while it runs.
  3. 3Attach the database to the app. DATABASE_URL appears in the container environment on the next deploy. Any other config goes in as environment variables through the dashboard.
  4. 4Run migrations — either the script above as a one-off, or wire it into your start sequence if you're a single-instance app and accept the tradeoff.
  5. 5Push to deploy. Every push to your connected branch triggers a build and rollout. Rollbacks and deployment history are there when a deploy goes sideways.

On the free tier, the app scales to zero when idle and cold-starts on the next request — this is exactly where the ahead-of-time build pays off, because a Fresh server with a prebuilt snapshot boots in very little time. Free-tier apps also run under gVisor sandboxing on preemptible nodes, which is the honest tradeoff: strong isolation and $0/month, in exchange for cold starts. If that stops being acceptable, paid tiers keep the app warm on stable nodes.

Recap

Fresh's production story is unusually short: build the snapshot, run main.ts, point DATABASE_URL at Postgres. The framework's whole philosophy — minimal client JS, no dependency directory, single-binary runtime — carries through to deployment, where the entire config is a twelve-line Dockerfile.

If you want to see it running with a database wired in, connect a repo at https://pandastack.io and push.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also