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 buildThis pre-compiles your islands and assets into a _fresh/ snapshot directory. When you then start the server:
deno run -A main.tsFresh 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 taskTwo 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 readPORTfrom 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.tsFor 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.tsdownloads and compiles every dependency at build time, so the running container never fetches anything over the network at boot. Withdeno.lockcommitted, this is fully reproducible.deno task buildruns 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 denodrops 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.-Agrants all permissions inside the container. You can tighten this to--allow-net --allow-env --allow-readif 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:
- 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.
- 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.
- 3Attach the database to the app.
DATABASE_URLappears in the container environment on the next deploy. Any other config goes in as environment variables through the dashboard. - 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.
- 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.