Qwik's whole pitch is resumability — instead of hydrating the page, it serializes application state into the HTML and resumes execution in the browser only when the user actually interacts. That design makes server rendering the *normal* mode for Qwik, not an add-on. Which means deploying Qwik is really about one decision: which adapter you build with. Get that right and the rest is a short path to production.
How a Qwik build actually works
A Qwik City project builds in two halves:
- a client build — the resumable chunks and static assets, output to
dist/ - a server build — the SSR bundle for whatever target your adapter defines
Out of the box, a fresh project (npm create qwik@latest) has no deployment target. Running the plain build gives you the client half and a warning that you haven't picked an adapter. The adapter is what generates the server entry, the extra Vite config, and the scripts that tie it together. Qwik ships adapters for Node servers (Express/Fastify), static generation, and the usual edge platforms.
For a container platform, the Express adapter is the straightforward choice:
npm run qwik add expressThis one command does three things worth knowing about, because they explain every file it touches:
- 1Creates
src/entry.express.tsx— a small, readable Express server that servesdist/as static assets and hands everything else to the Qwik City request handler. - 2Creates
adapters/express/vite.config.ts— the Vite config for the server build. - 3Adds scripts to
package.json, including abuild.serverscript (vite build -c adapters/express/vite.config.ts) and aservescript that runs the built server from theserver/directory.
After that, the production build is just:
npm run buildQwik's CLI orchestrates the whole set — type checking, the client build into dist/, and the server build into server/. The start command for production is the generated server entry:
node server/entry.expressThe generated Express entry reads PORT from the environment (falling back to 3000), which is exactly what a container platform needs — the router injects a port, your process binds to it, done. Don't hardcode a port anywhere, and if you edit the entry file, keep that behavior.
The static option
If your site has no per-request logic — a marketing site, docs, a blog — Qwik can pre-render everything:
npm run qwik add staticThis wires up static generation so the build emits plain HTML plus Qwik's resumable chunks into dist/. You'll be asked for your site's origin URL during setup — it's used to generate correct absolute links, so use the real production domain, not localhost.
A static Qwik build deploys like any other static site: on PandaStack you'd connect the repo as a static site rather than a container app, the build runs, and dist/ gets served with SSL on your domain. No server process, no cold starts, nothing to scale. If you're not sure whether you need SSR yet, starting static and switching adapters later is a genuinely small change — that's the nice thing about Qwik keeping the adapter as a thin layer.
The rest of this tutorial assumes the SSR path, since that's where databases and env vars come into play.
Environment variables, the Qwik City way
Qwik splits env access by where the code runs, and it's stricter about it than most frameworks:
Client-visible values must be prefixed with PUBLIC_ and are read via import.meta.env:
const apiBase = import.meta.env.PUBLIC_API_URL;These are baked in at build time. Changing one means rebuilding, not restarting — worth remembering when a dashboard edit appears to do nothing.
Server-only values are read at runtime through the request event, not process.env:
import { routeLoader$ } from '@builder.io/qwik-city';
export const useSettings = routeLoader$(async (requestEvent) => {
const apiKey = requestEvent.env.get('API_KEY');
// ...
});requestEvent.env.get() is the portable API — it works the same whether the app runs on Node or an edge runtime. Inside entry.express.tsx itself (plain Node code, outside Qwik's request handling), process.env is fine.
Wiring in a managed Postgres
Server-rendered Qwik plus a database gets you loaders that query directly — no separate API service. On PandaStack, provision a managed PostgreSQL instance (14.x and 16.x run in production there) and attach it to your app. DATABASE_URL is injected into the app's environment automatically, so the connection string never gets copy-pasted between dashboards.
A small connection module:
// src/lib/db.ts
import postgres from 'postgres';
let sql: ReturnType<typeof postgres> | undefined;
export function getDb(url: string) {
sql ??= postgres(url, { max: 5 });
return sql;
}And a route loader that uses it:
// src/routes/posts/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import { getDb } from '~/lib/db';
export const usePosts = routeLoader$(async (requestEvent) => {
const sql = getDb(requestEvent.env.get('DATABASE_URL')!);
return await sql`select id, title from posts order by id desc limit 20`;
});
export default component$(() => {
const posts = usePosts();
return (
<ul>
{posts.value.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
});Two production notes. Keep the pool small — every instance holds its own connections, and managed plans enforce limits (50 connections on PandaStack's free tier, 300 on Pro). And note the loader runs only on the server, so the driver and credentials never reach the client bundle; that's Qwik working as designed.
For migrations, Qwik has no opinion, so bring your own — Drizzle or Prisma both work fine here. Run them as an explicit step against DATABASE_URL before the new version takes traffic, never inside the app's startup path, so parallel instances can't race on schema changes.
A Dockerfile for Qwik
PandaStack's Node buildpack can detect and build the app without one, but if you want the build pinned down exactly:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server
USER node
EXPOSE 3000
CMD ["node", "server/entry.express"]Both build outputs matter: dist/ (client assets the Express server serves statically) and server/ (the SSR bundle). Ship one without the other and you'll get either a blank page or a 404 storm on chunk requests.
Going live
The end-to-end on PandaStack:
- 1Push the repo to GitHub and connect it as a container app (or a static site, for the static adapter).
- 2Attach a managed Postgres if you need one —
DATABASE_URLappears in the environment on its own. - 3Set
PUBLIC_*variables and server secrets in the app's environment settings. - 4
git push. The image builds via rootless BuildKit in an ephemeral pod, build logs stream live, and the app comes up behind SSL. Deployment history gives you one-click rollbacks when you need them.
Fair warning on the free tier: idle apps scale to zero, so the first hit after a quiet stretch pays a cold start. Qwik's fast startup makes this less painful than for heavier frameworks, but for steady production traffic you'll want a paid tier where the instance stays warm.
Qwik's adapter model means the framework meets the platform halfway — one command to target Node, one build, one entry file to run. If you want to see the push-to-live loop for yourself, it's a few minutes' work on https://pandastack.io.