all posts

How to deploy a Qwik app

Ajay Kumar··10 min read

Most framework deployment guides are interchangeable. Install dependencies, run a build, start a process, bind a port, put TLS in front. Qwik is the one in this series where that summary hides something real, because Qwik does not hydrate — it resumes — and resumability changes what the server is actually producing. If you deploy it as though it were just another SSR framework you will get something that works, but you will also get the class of bugs that only shows up in production: a page that renders and then does nothing when clicked, a first paint that fires forty requests, an action that returns a 404 because you built for a target that has no server at request time.

So this post spends its first half on why Qwik is different, and its second half on shipping it. The first half is not throat-clearing. Every deployment decision below — which adapter, which caching headers, which environment variable mechanism, which bind address — follows directly from how resumability works.

What resumability actually changes

A conventional SSR framework sends you HTML and then sends you the application again as JavaScript. The browser downloads the component tree, executes it, rebuilds the virtual DOM in memory, walks the real DOM, and attaches event listeners until the two agree. That process is hydration, and its cost scales with the size of your application rather than with the size of the page the user asked for. It is why a large React or Vue app can paint in 400 milliseconds and stay unresponsive for two seconds afterwards.

Qwik skips it. The server renders HTML and, in the same pass, serialises two things into that HTML: the application state, and references to the event handlers. There is no client-side replay of the component tree, because there is nothing to replay — the state the client needs is already in the document, and the handlers are addressed by pointer rather than by re-execution.

Illustratively, the output looks roughly like this, with real hashes elided:

<!-- The listener is a pointer, not a closure the client had to rebuild -->
<button on:click="/build/q-a1b2c3d4.js#s_0Vp5EDvxOJ4">
  Add to cart
</button>

<!-- ... and at the end of the document, the serialised application state -->
<script type="qwik/json">
  {"objs":[...],"subs":[...],"ctx":{...}}
</script>

<!-- ~1KB of inline script: one delegated listener, nothing else -->
<script id="qwikloader">...</script>

The inline qwikloader registers a small number of delegated event listeners on the document and then stops. Nothing else runs. When a user clicks that button, the loader reads the attribute, fetches exactly that one chunk, deserialises exactly the state that closure needs, and executes it. The rest of your application never downloads unless someone interacts with it.

That is what the dollar-sign suffix is for. Every component$, useTask$, and onClick$ in your source is a marker to the Qwik optimizer — a Vite plugin — saying that this closure is a lazy-loading boundary. The optimizer extracts each one into its own symbol, and that is where the chunk count comes from. A modest Qwik app can emit a few hundred small JavaScript files, on purpose. Hold on to that fact; it comes back when we talk about caching.

So what is the server actually for?

Three jobs, and it is worth naming them separately because different adapters support different subsets.

  1. Render the HTML and serialise the state into it. This is the expensive part of a Qwik response and it happens on every request that is not prerendered. If the page is the same for every visitor, doing it per request is waste — which is the argument for the static adapter.
  2. Run the request-time server code: routeLoader$ for data a page needs before it renders, routeAction$ for form submissions, onGet and onPost endpoint handlers, and server$ for typed RPC from the client back to the server.
  3. Serve the client build — the many small chunks, plus your static assets. This is a file server, and it does not have to be the same process as the first two.

Splitting them out matters because a serialisation failure is a deployment failure. Qwik has to be able to serialise everything the client might need. Put a class instance, a database client, or a raw function into a store and the render throws at serialisation time rather than at write time — often only on the page that actually reads it, which means it survives your smoke test and dies in production. The escape hatch is noSerialize, and the discipline is that anything server-only should stay in a routeLoader$ or a server$ closure and never end up in component state.

Test a build, not just the dev server, before you deploy. Serialisation errors and optimizer-boundary mistakes behave differently under vite dev than under a production SSR build, and the first time most people meet one is on a host, in a log they are not yet watching.

Choosing the adapter is choosing your deployment model

Qwik City ships this decision as an explicit command. You do not configure a target in a config file that someone might change by accident; you run a generator that writes an entry file and a build config into your repository, where you can read them.

# Interactive menu of every available integration:
npm run qwik add

# Or name the adapter directly:
npm run qwik add express          # Express server -> server/entry.express.js
npm run qwik add node-server      # plain node:http server, no framework
npm run qwik add fastify
npm run qwik add static           # prerender everything at build time
npm run qwik add cloudflare-pages
npm run qwik add vercel-edge
npm run qwik add netlify-edge
npm run qwik add aws-lambda

# What it changed:
git diff --stat
#  adapters/express/vite.config.ts | 24 ++++++
#  src/entry.express.ts            | 41 ++++++++++
#  package.json                    |  4 +++

Those generated files are yours now. The entry is ordinary source code you are expected to edit — that is the intended workflow, not a hack — and the adapter vite config is where prerendering and origin settings live.

The three broad choices, and what each one costs:

  • static — What it does: prerenders every route to HTML at build time and emits a folder you can put on any CDN with no process running. What it cannot do: routeAction$, server$, onPost endpoints, or any routeLoader$ whose answer depends on the request. What it costs: nothing to run, no cold start, nothing to patch. The right default for docs, marketing, and content sites.
  • node server (express, fastify, or node-server) — What it does: everything. SSR per request, routeLoader$ against a database, routeAction$ form posts, server$ RPC, streaming responses, Node built-ins, native modules, a filesystem. What it costs: a long-lived process holding memory whether or not anyone is visiting, and a runtime you are responsible for keeping patched. This is the portable choice — it runs on a VPS, a container platform, a microVM, or your laptop, identically.
  • edge and serverless (cloudflare-pages, vercel-edge, netlify-edge, aws-lambda, deno, bun) — What it does: SSR and server endpoints executed close to the user, scaled by the platform. What it cannot do reliably: Node built-ins, native modules, long-running work, and anything assuming a writable filesystem. What it costs: per-invocation billing plus the runtime's constraints leaking into your application code, which is the part that is genuinely hard to reverse later.
  • The reversibility gradient — Swapping adapters is mostly config, right up until it is not. Moving static to node is painless. Moving node to edge means auditing every server$ and routeLoader$ for Node APIs. Moving edge back to node is easy, which is a decent argument for starting on node and migrating outward once you know which routes actually need to be near the user.
You can have more than one. A common shape is the node adapter with prerendering enabled for the marketing and docs routes, so those are served as flat files while the application routes render per request. Configure it in the adapter vite config rather than by maintaining two deployments.

What the build actually produces: dist/ and server/

This trips people up because a Qwik build is two builds, and they write to two different places. Run it once locally and look, before you ask a platform to do it for you.

npm ci
npm run build     # runs build.client, build.types, and build.server

ls dist/
# index.html            <- only if you prerendered routes
# build/                <- the many small hashed chunks (q-*.js)
# q-manifest.json       <- symbol -> chunk map the SSR render needs
# favicon.svg, robots.txt, ...

ls server/
# entry.express.js      <- your SSR server (with the express adapter)
# @qwik-city-plan.js
# assets/

ls dist/build/ | wc -l
# a few hundred files is normal and not a misconfiguration

Two consequences follow immediately. First, dist/ alone is not your application. If a platform detects a Vite project and helpfully serves dist/ as a static site, you get the client bundle without the server: pages that were prerendered will load, and every routeLoader$, routeAction$, and endpoint returns a 404. That failure looks like a routing bug and is actually a build-output misunderstanding.

Second, q-manifest.json is not decoration. The SSR render reads it to turn symbol references into chunk URLs, and it also drives the prefetch strategy Qwik injects into the page. A deployment that ships a server built against one manifest and a dist/ from another build will render attributes pointing at chunk names that no longer exist. Always build both halves in the same run — which is what npm run build already does, so the way this goes wrong is by trying to be clever with caching or partial artifact uploads.

// src/entry.express.ts — generated by `npm run qwik add express`, then edited.
import { createQwikCity, type PlatformNode } from "@builder.io/qwik-city/middleware/node";
import qwikCityPlan from "@qwik-city-plan";
import { manifest } from "@qwik-client-manifest";
import render from "./entry.ssr";
import express from "express";
import { fileURLToPath } from "node:url";
import { join } from "node:path";

declare global {
  interface QwikCityPlatform extends PlatformNode {}
}

const distDir = join(fileURLToPath(import.meta.url), "..", "..", "dist");
const buildDir = join(distDir, "build");

const { router, notFound } = createQwikCity({ render, qwikCityPlan, manifest });

const app = express();

// Content-hashed chunks: safe to cache forever.
app.use("/build", express.static(buildDir, { immutable: true, maxAge: "1y" }));
// Everything else in dist/: shorter, revalidated.
app.use(express.static(distDir, { redirect: false }));

app.use(router);
app.use(notFound);

const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? "0.0.0.0";

app.listen(port, host, () => {
  console.log("qwik listening on " + host + ":" + port);
});

That is close to what the generator writes, with the bind address made explicit. Hold that thought too.

Why the chunk count makes your headers matter more than usual

Fine-grained lazy loading is the whole point of Qwik, and it moves work from the JavaScript engine to the network. Instead of one 300 KB bundle you have hundreds of files measured in hundreds of bytes, fetched on demand — and warmed ahead of demand, because Qwik injects a prefetch strategy that speculatively pulls the chunks the current page is likely to need.

On a good connection with HTTP/2 or HTTP/3 multiplexing and correct caching, this is excellent: nothing downloads that the user never touches, and the things they do touch are already local. On a badly configured origin it is worse than a bundle, because you have converted one round trip into many. The difference is entirely in how you serve dist/build.

  1. Everything under /build is content-hashed. Cache it immutably — Cache-Control: public, max-age=31536000, immutable. A new build produces new filenames, so a cached old chunk is never wrong, only unused. The express entry above already does this; if you put nginx or a CDN in front, make sure it does not override it with something conservative.
  2. HTML and q-manifest.json must not be cached hard. They are the files that point at the current chunk names. A stale HTML document referencing chunks a deploy deleted produces a page that renders and then silently fails to respond to clicks — the Qwik version of the classic stale-index.html bug, and harder to spot because there is no blank screen to report.
  3. Serve /build over a connection that multiplexes. HTTP/2 or better is not a nice-to-have here. On HTTP/1.1 with six connections per origin, a few dozen concurrent chunk requests queue, and the prefetch that was supposed to help becomes head-of-line blocking.
  4. Enable compression, and check that it is actually applied to the small files. Some proxies have a minimum size threshold below which they skip gzip or brotli. Qwik chunks frequently fall under it, which is exactly backwards for this workload.
  5. Put the chunks on a CDN if your users are not near your server. The SSR render has to happen somewhere; the chunks do not. This is the split that makes a single-region node server perfectly reasonable for a global audience.
Watch out for aggressive request-rate limiting or bot protection in front of your app. A single legitimate first paint can request dozens of small files in a burst, which some default WAF rules read as scraping. The symptom is an app that works for you and intermittently half-works for users on slower networks, which is a miserable thing to debug.

Environment variables: build time, runtime, and the platform-agnostic accessor

Qwik City has two mechanisms and they are not interchangeable. Getting this wrong is how public deployments end up with a private key in a JavaScript file.

  • import.meta.env.PUBLIC_* — inlined into the bundle at build time by Vite. Available on the client. Public in the strongest sense: a literal string in a file anyone can download. Changing one requires a rebuild, because there is no runtime lookup left to change. Use it for your API base URL, an analytics site ID, a feature flag.
  • requestEvent.env.get('NAME') — read at request time, server side only, inside routeLoader$, routeAction$, endpoint handlers, and server$ closures. This is where secrets go. It also abstracts over the target: on a node adapter it reads process.env, on Cloudflare it reads the platform bindings, so the same code moves between adapters unchanged.
  • process.env directly — works on node adapters and nowhere else. It is the reason a codebase that ran fine on Express breaks on an edge adapter with an undefined it never had locally. Prefer env.get even when you have no plans to move; the cost is zero and it keeps the door open.
// src/routes/orders/index.tsx
import { routeLoader$, routeAction$, zod$, z } from "@builder.io/qwik-city";

export const useOrders = routeLoader$(async (requestEvent) => {
  // Runtime, server-only, adapter-agnostic. Never reaches the client bundle.
  const token = requestEvent.env.get("ORDERS_API_TOKEN");
  if (!token) throw requestEvent.error(500, "ORDERS_API_TOKEN is not set");

  const res = await fetch(process.env.ORDERS_API_URL + "/orders", {
    headers: { authorization: "Bearer " + token },
  });
  return (await res.json()) as { id: string; total: number }[];
});

export const useCreateOrder = routeAction$(
  async (data, requestEvent) => {
    const token = requestEvent.env.get("ORDERS_API_TOKEN");
    // ... POST, then return a serialisable result
    return { success: true };
  },
  zod$({ sku: z.string().min(1), qty: z.coerce.number().int().positive() })
);

// Client-visible, build-time, and public by construction:
const analyticsId = import.meta.env.PUBLIC_ANALYTICS_ID;

The practical rule is the same one that applies to every framework with a build step, and it is worth saying plainly: if a value differs between staging and production and you want to change it without rebuilding, it must be read at runtime. If it is inlined at build time, setting it on the platform and restarting does nothing at all, because the old value is already compiled into a file. Half the confused deployment threads about environment variables are this, in one framework's dialect or another.

The bind address that breaks first deploys

Here is the failure that costs more first-deploy hours than everything above combined, on every platform, in every framework. Your build succeeds. Your server starts. Your log says it is listening. The platform reports the deployment as failed because the health check timed out, and you spend twenty minutes convinced the platform is broken.

It is the bind address. A process that listens on 127.0.0.1 is reachable only from inside its own network namespace. On PandaStack each app runs in its own Firecracker microVM with its own Linux network namespace and TAP device, so a health check arriving over the guest's network interface finds nothing listening — the socket exists, but not on the address the packet was sent to. The same thing happens inside a container, a Kubernetes pod, or a VM behind a proxy. Localhost means something narrower than people expect, and the error it produces looks like a timeout rather than a refusal.

# Reachable only from inside the guest. Health check times out.
app.listen(3000, "127.0.0.1")
app.listen(3000, "localhost")

# Reachable from the platform's router. This is what you want.
app.listen(Number(process.env.PORT ?? 3000), "0.0.0.0")

# Verify from inside the running app before blaming the platform:
ss -ltnp | grep node
# LISTEN 0 511 127.0.0.1:3000  <- broken
# LISTEN 0 511    0.0.0.0:3000  <- correct

The second half of the same rule: read the port from the environment. Platforms assign a port and export it; a server hardcoded to 3000 will be healthy on a router expecting 8080 and reported dead. Both halves are one line in the entry file the adapter generated for you, which is precisely why it generated a file you can edit rather than hiding the server behind a config key.

If you export PORT or HOST yourself in a start command, export them before the process launches rather than passing them as inline prefixes through a wrapper. Variables that are not exported in the launching shell do not expand where you expect, and the resulting server binds to its default while your log line confidently prints the value you intended.

Deploying it: connect the repo

With the node adapter in place, Qwik is a Node process serving HTTP on a port, which is the most boring and most portable thing a web application can be. Any host that runs one will do — systemd on a VPS, a container platform, or a git-driven platform that builds from your repo. The rest of this walks the git-driven path, because it is the one where the Qwik-specific details actually matter.

pandastack app create \
  --name storefront \
  --git-url https://github.com/acme/qwik-storefront \
  --git-branch main \
  --install-command "npm ci" \
  --build-command "npm run build" \
  --start-command "node server/entry.express" \
  --env NODE_ENV=production,PUBLIC_ANALYTICS_ID=abc123

# Watch the build stream. Failures land here, not in a support ticket.
pandastack app deploy <app-id> --follow

Be explicit about the start command. Framework detection on most platforms recognises a Vite project from your package.json, and the safe generic assumption for a Vite project is a static build served from dist/. For Qwik that assumption is wrong in a specific and confusing way: the app appears to deploy, prerendered pages load, and every server route 404s. Naming node server/entry.express removes the guess entirely. The same applies if you used the fastify or node-server adapter — point at whatever entry the generator wrote.

If you went the static route instead, do the opposite: build, then serve the output directory, and you never run a process at all.

# Static adapter: no server, no cold start, nothing to pay for while idle.
pandastack app create \
  --name docs \
  --git-url https://github.com/acme/qwik-docs \
  --build-command "npm run build" \
  --start-command "npx --yes serve -s dist -l \$PORT"

# Monorepo? Point at the package, not the repo root:
#   --root-directory apps/storefront

Pin the runtime version

Qwik and the Vite version it depends on both have a Node floor, and a build that runs on your laptop's Node 22 and the platform's Node 18 will fail somewhere unhelpful — often inside a transitive dependency, with a syntax error rather than a version message. Pin it in the repository using the idiomatic file for your ecosystem, and let the platform's version manager honour it.

# The simplest pin, understood almost everywhere:
echo "22" > .nvmrc

# Or be explicit about the whole toolchain in one file:
cat > mise.toml <<'EOF'
[tools]
node = "22"
EOF

# Belt and braces — this one fails the install rather than the build,
# which is a much better error message to receive:
npm pkg set engines.node=">=20.11"

On PandaStack the build runs inside the app's own microVM and mise reads whichever of those files is present, so a pin in the repo is the whole configuration. It also means the version that built your app and the version that runs it are the same by construction, which removes a category of bug that is very hard to reason about from the outside.

First-deploy debugging: build log, then runtime log

These are two different streams and mixing them up wastes real time. The build log covers clone, install, and build. The runtime log is your process's stdout and stderr after it started. A serialisation error appears in the second; a missing dependency appears in the first; a bind-address mistake appears in neither, because nothing went wrong from the process's point of view.

APP_ID=app_...

# 1. Which deployment, and did it get as far as starting?
pandastack app deploys $APP_ID

# 2. Build stream: clone -> npm ci -> npm run build
pandastack app logs $APP_ID $DEPLOY_ID

# 3. Your app's own stdout/stderr, once it started
curl -sN "https://api.pandastack.ai/v1/apps/$APP_ID/runtime-logs?follow=1" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY"

# 4. Nothing in either? It is almost always the bind address or the port.
#    Roll back while you work it out — the previous deployment is still there.
pandastack app rollback $APP_ID

A short triage table for the failures specific to Qwik, in the order you should check them:

  • Build succeeded, page renders, clicking does nothing — Look for a chunk 404 in the browser network tab. Usually a mismatch between the served dist/build and the manifest the server was built against, or a CDN still holding an old HTML document that points at deleted chunks.
  • Build succeeded, every server route returns 404 — You are serving dist/ statically instead of running server/entry.*. Set the start command explicitly.
  • Deployment reported unhealthy, log says it is listening — Bind address is 127.0.0.1, or the port is hardcoded rather than read from the environment.
  • Error mentioning serialisation or an object that cannot be serialised — Something non-serialisable made it into component state. Wrap it in noSerialize, or move it into a routeLoader$ or server$ closure where it never crosses the boundary.
  • Works locally, undefined variable in production — A secret read via process.env or import.meta.env where it should have been requestEvent.env.get, or a PUBLIC_ variable that was set at runtime instead of at build time.

The economics for a side project

Here is the awkward part of the node adapter, stated honestly. A Qwik SSR server is exceptionally cheap per request — the render is fast and the client does almost no work — but it is a long-lived process, and a long-lived process that nobody visits still occupies memory. For a side project, a demo, or an internal tool, the bill is dominated not by traffic but by the hours between traffic.

There are three honest answers to that. One: use the static adapter if your app can tolerate it, because a folder of files on a CDN costs approximately nothing and never cold-starts. Two: use an edge adapter and pay per invocation, accepting the runtime constraints. Three: keep the node server and put it somewhere that can genuinely stop running when idle and restart quickly enough that nobody notices.

The third is what PandaStack apps do. Each app is a Firecracker microVM with KVM hardware isolation, not a shared-kernel container — the guest gets its own kernel, its own network namespace, and its own TAP device, so a runaway build or a dependency doing something regrettable is contained by the hypervisor rather than by a seccomp policy. When the app goes idle, the VM's memory is snapshotted and it stops costing anything. When a request arrives, it comes back by restoring that snapshot rather than by booting: our snapshot-restore create path runs at roughly 179ms at p50 and 203ms at p99, against about 3 seconds for a genuine cold boot of the same template. That gap is the whole argument for snapshots over warm pools.

For Qwik specifically this composes nicely, because the expensive-to-restart part of your deployment is the server process and the frequently-requested part is a pile of immutable static chunks. Put the chunks behind a CDN, let the SSR server sleep, and the idle cost of a Qwik application approaches the cost of the static site it partly is.

If your server holds in-memory state that matters — a cache, a session map, a rate-limit counter — sleeping and restoring will surface every assumption you made about it. That is worth knowing before it happens, and it is a good argument for putting that state in a database from the start rather than discovering the dependency during an incident.

The pre-launch checklist

  1. Run npm run build and confirm both dist/ and server/ exist. If server/ is missing, your adapter is not installed and you are about to ship a static site by accident.
  2. Start the built server locally with PORT set to something unusual and confirm it listens on that port and on 0.0.0.0, not 127.0.0.1.
  3. Click something. Then open the network tab and watch the chunk load. A page that renders but never fetches a chunk on interaction means the qwikloader is not finding the build directory.
  4. Submit a routeAction$ form against the production build, not the dev server. This is the check that catches a static adapter chosen by mistake, and nobody does it during a smoke test.
  5. Confirm /build is served with a long immutable cache lifetime and your HTML is not. Deploy twice and hard-refresh in between; if the second deploy leaves anyone on stale chunks, your HTML caching is wrong.
  6. Grep the built client bundle for anything secret before the first public deploy, not after: grep -r 'sk_live' dist/ and the equivalent for your own key prefixes.
  7. Point your health check at a route that does not touch the database. Otherwise a slow query becomes a restart loop, and a degraded service becomes an outage.

The short version

Qwik moves work off the client by serialising state and handler references into the HTML, which means the server's job is to render and serialise, and the network's job is to deliver a lot of very small files quickly. Deploying it well is therefore four decisions: pick the adapter deliberately because it is your deployment model, build and ship dist/ and server/ from the same run, cache the hashed chunks immutably while never caching the HTML that points at them, and read your port and bind address from the environment.

Get those four right and Qwik is one of the least demanding frameworks to operate — a small Node process and a large pile of cacheable files, which is a shape the internet has been good at for thirty years.

Frequently asked questions

Which Qwik adapter should I use in production?

Start with the node adapter unless you have a specific reason not to, because it is the only one that supports the whole framework and it runs identically on a VPS, a container platform, a microVM, or your laptop. Use the static adapter when every route can be prerendered — content sites, documentation, marketing pages — since it gives you a folder of HTML with no process to run, no cold start, and effectively no idle cost. Reach for an edge adapter when latency to a globally distributed audience genuinely dominates your performance budget and you are willing to give up Node built-ins, native modules, and a filesystem in exchange. The migration cost is asymmetric and worth planning around: moving from node to edge means auditing every server$ and routeLoader$ for APIs the edge runtime does not have, whereas moving from edge back to node is close to free. Starting on node and moving specific routes outward later is usually the cheaper sequence.

Why does my Qwik page render but not respond to clicks in production?

Almost always a chunk that cannot be fetched. Resumability means the click handler is not in the page — the element carries a pointer to a lazily loaded chunk, and the qwikloader fetches it when the event fires. If that fetch 404s, the render is perfect and the interaction is dead, with no error until you open the network tab. Three common causes: a stale HTML document cached by a CDN that points at chunk filenames a later deploy deleted; a server bundle built against a different q-manifest.json than the dist/build you actually shipped, which happens when artifacts are assembled from separate builds; or a base path or reverse-proxy rule that rewrites requests to /build so they never reach the static files. Check the network tab first, confirm the requested chunk filename exists in your deployed dist/build, and fix the caching or the artifact pairing rather than the application code.

How do I handle secrets in a Qwik City app?

Use requestEvent.env.get inside routeLoader$, routeAction$, endpoint handlers, or server$ closures, and never put a secret behind a PUBLIC_ prefix. Anything prefixed PUBLIC_ is substituted into the client bundle by Vite at build time, which means it becomes a literal string in a JavaScript file that every visitor downloads and anyone can read with a text editor; minification does not hide it. The env.get accessor is read at request time and is server-only, and it also abstracts over the deployment target — on a node adapter it reads process.env, on Cloudflare it reads the platform bindings — so the same code keeps working if you change adapters. Reading process.env directly works on node adapters and silently returns undefined on edge ones, which is a nasty way to discover a migration problem. A useful habit before your first public deploy: grep your built client bundle for your key prefixes and confirm nothing sensitive is in there.

Do I need a Dockerfile to deploy Qwik?

Usually not. A Qwik app with the node adapter has no system dependencies beyond Node itself — the build output is JavaScript, the runtime is one binary, and there is no image library, headless browser, or compiled extension in the picture. A Dockerfile in that situation buys you reproducibility that you can get more cheaply by pinning the Node version in a .nvmrc or mise.toml, and costs you an image build in every CI run plus a registry to keep tidy. Most git-driven platforms will detect the project, run your install and build commands, and start the entry file. The moment to add a container is when something changes that argument: a native dependency that needs a system package, a sidecar process, a toolchain your platform's runtime detection cannot express, or a compliance requirement that the exact artifact tested is the exact artifact promoted. Until one of those is true, a Dockerfile whose only job is to run node server/entry.express is overhead.

Does Qwik's chunk count hurt performance in production?

Only if you serve it badly, and then it hurts a lot. The large number of small files is deliberate — it is how Qwik avoids downloading code the user never interacts with — and Qwik pairs it with a prefetch strategy that speculatively warms the chunks the current page is likely to need. On HTTP/2 or HTTP/3, with content-hashed chunks cached immutably and compression actually applied to small files, this is strictly better than one large bundle because the bytes you skip are real. The failure modes are all infrastructure: HTTP/1.1 with its six-connections-per-origin limit turns a burst of chunk requests into head-of-line blocking; a proxy with a minimum-size threshold for gzip skips compression on exactly the files that dominate the count; and rate limiting or bot protection tuned for page views can read one legitimate first paint as scraping. Serve /build from a CDN with a long immutable cache lifetime and none of these apply.

Can I prerender some Qwik routes and server-render others?

Yes, and it is usually the right shape for a real application. Keep the node adapter so you have a server for the routes that need one, and enable static generation in the adapter's vite config for the routes that do not — typically the marketing pages, documentation, changelog, and anything else identical for every visitor. Those routes get written to dist/ as HTML at build time and served as flat files, while the application routes render per request with full access to routeLoader$, routeAction$, and server$. The advantage over maintaining two separate deployments is that they share one origin, one set of build artifacts, one manifest, and one deploy, which removes the whole category of bugs where a shared component drifts between the two. The thing to check is that a route you marked prerenderable does not quietly depend on the request — a cookie, a header, a logged-in user — because prerendering it will bake one visitor's answer into a file served to everyone.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.