Qwik is a modern web framework built for instant loading by default, using “resumability” to ship minimal JavaScript and wake up only the code a user needs. That design is brilliant in development and in production—provided your deployment matches how Qwik builds both its client and server outputs.
Below is a pragmatic, one-pass guide to ship a Qwik City app on DigitalOcean using two production-ready paths: App Platform (zero-ops) and a Droplet with Docker (full control). You’ll need a DigitalOcean account before you begin; if you do not have one yet, create it now so you can follow along.
What you’ll deploy:
Qwik City (the app framework around Qwik) produces a client build and, when you add a Node adapter, a Node server build. The production build creates a dist/
folder for static assets and a server/
folder with the entry point for your chosen server (Express or Fastify). In production you start that server entry file, and it serves routes plus the static assets. We’ll lean on that behavior in both deployment paths.
Prerequisites:
- Node.js 20+ on your local machine (use LTS for predictability).
- A Qwik City app with a Node adapter (Express or Fastify). If you don’t have the adapter yet, add one now.
# in your Qwik project
npm run qwik add express
# or:
npm run qwik add fastify
Build for production to confirm the app compiles locally:
npm run build
The build should produce dist/
(static) and server/
(server files), which you will run in production.
Critical production settings
Qwik City includes CSRF protection. In production you must set the ORIGIN
environment variable to your public origin (scheme + host, optional path base). If your app will live at https://dropletdrift.com
, set ORIGIN=https://dropletdrift.com
. We’ll show where to set it on App Platform and in Docker.
Also, when running on App Platform, your process must listen on the port provided by the platform (via the PORT
env var or the service’s configured HTTP port), not on a hard-coded local port. We’ll wire that below.
Path 1 — App Platform (Git → buildpacks)

App Platform’s Node buildpack will detect your app and run your build and run commands. Add a production start script that launches Qwik’s server entry (Express shown; use Fastify if that’s what you added).
{
"scripts": {
"build": "vite build && vite build --ssr",
"start": "node server/entry.express",
"serve": "node server/entry.express"
},
"engines": {
"node": "22.x"
}
}
Pinning Node ensures you build and run on the same runtime; App Platform currently defaults to a 22.x line if you don’t specify one.
Update your server entry (if generated by qwik add express
, it likely already reads a port). Ensure it honors process.env.PORT
:
// server/entry.express.ts (or .js after build)
import express from 'express';
import { qwikCity } from '@builder.io/qwik-city/middleware/express';
import render from './entry.ssr';
const app = express();
app.use(qwikCity({ render }));
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
app.listen(port, () => {
console.log(`Qwik app listening on port ${port}`);
});
Connect your GitHub/GitLab repo in Create → Apps in the DigitalOcean control panel. App Platform will detect Node from package.json
. If you have both a Node server and static files, keep this as a Web Service.
In the “Commands” section for your service, set:
- Build Command:
npm run build
- Run Command:
npm start
You can adjust these later in Settings → Commands if needed.
In Settings → Ports, leave the Public HTTP Port at the default (often 8080
). App Platform will provide a PORT
env var to your process and route external traffic to it. Because your server reads process.env.PORT
, you don’t need to hard-code a port.
In Settings → Environment Variables:
ORIGIN
= your public origin (for example,https://your-app.ondigitalocean.app
initially; update to your custom domain later).- Any app secrets (encrypt these).
- Optional: bindable variables like
${APP_URL}
can be referenced at runtime.
App-level and component-level variables can be configured and encrypted; bindables (like ${APP_URL}
) are provided by App Platform.
Deploy. Once live, visit the default URL shown for the app. If forms/actions fail with a CSRF message, verify ORIGIN
exactly matches your final URL (including https
). You can change variables without a code push and redeploy.
In the app’s Domains settings, add your domain; App Platform provisions TLS and handles HTTP→HTTPS. No extra Nginx is required.
Scale instance size and count in Settings → Resources, and check Logs and the Console to verify variables (e.g., echo $ORIGIN
). Clear the build cache if a dependency pin changes.
Path 2 — Droplet with Docker (container control)
If you prefer explicit control—custom Node versions, private networking, sidecars—package the Qwik server into a Docker image and run it on a Droplet (or deploy that image to App Platform as a container resource).
This multi-stage Dockerfile builds your Qwik app, then runs only the output. It uses Node 22 on Alpine; adjust as needed.
# syntax=docker/dockerfile:1.6
ARG NODE_VERSION=22.6.0
FROM node:${NODE_VERSION}-alpine AS base
WORKDIR /usr/src/app
# Install dependencies in a cached layer
FROM base AS deps
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
RUN --mount=type=cache,target=/root/.npm npm ci || npm install --production=false
# Build the application
FROM deps AS build
COPY . .
RUN npm run build
# Minimal runtime image
FROM node:${NODE_VERSION}-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /usr/src/app
# Qwik City CSRF/Origin check: set this at deploy time
ENV ORIGIN="https://dropletdrift.com"
# Copy production files
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/dist ./dist
COPY --from=build /usr/src/app/server ./server
# Expose the app port (container-internal)
EXPOSE 3000
# IMPORTANT: server must read PORT if provided (e.g., App Platform)
ENV PORT=3000
CMD ["node", "server/entry.express"]
Qwik’s Docker cookbook highlights the same build/run split and the need to set ORIGIN
. If you deploy the image to App Platform instead of a raw Droplet, App Platform can inject PORT
and HTTP routing automatically.
Build and run locally:
docker build -t my-qwik:prod .
docker run --rm -e ORIGIN=http://localhost:3000 -e PORT=3000 -p 3000:3000 my-qwik:prod
Deploy the image:
- Droplet: install Docker, push your image to a registry, pull and run on the Droplet (optionally with systemd).
- App Platform: choose “Deploy from container image” and point to your registry image; App Platform will handle HTTPS, scaling, and logs.
Optional — App spec as code
If you prefer GitOps/CLI, define an app spec (app.yaml
) and create or update the app via doctl
/API. This example uses a repo source and the Node buildpack:
name: qwik-on-app-platform
region: ams
services:
- name: web
source_dir: /
http_port: 8080
run_command: "npm start"
build_command: "npm run build"
envs:
- key: ORIGIN
value: ${APP_URL}
github:
repo: your-org/your-repo
branch: main
deploy_on_push: true
The app spec is the canonical, versioned configuration for your app—domains, region, env vars, and per-component settings. You can create/update the app with doctl apps create --spec app.yaml
or the API.
Environment variables inside Qwik:
Inside Qwik City, prefer import.meta.env
for client-safe values and request-scoped access for server code; avoid using process.env
in client-side code. On App Platform, define variables at app or component level; you can encrypt secrets and reference dynamic variables like ${APP_URL}
.
Common pitfalls and quick fixes
- CSRF errors on actions/forms:
ORIGIN
must exactly match your public origin (scheme + host, plus any app base path). Update it after attaching a custom domain. - App boots locally but not on App Platform: ensure your server listens on
process.env.PORT
when present; App Platform routes traffic to that port. - Wrong Node version: pin Node in
package.json → engines
so build and runtime match. Clear build cache and redeploy after changing it. - Static-only site: if you’ve generated a purely static build, you can deploy as a “Static Site” on App Platform; otherwise keep it as a Web Service for SSR. (Port and run command apply only to services.)
Checklist before you call it done:
npm run build
completes cleanly and producesdist/
andserver/
.- Server entry uses
process.env.PORT || 3000
. ORIGIN
is set to the final public URL.- On App Platform, Build Command and Run Command are set; env vars are configured and secrets encrypted.
- Logs show the app listening on the expected port; the health check passes.
Next steps you can take:
Add autoscaling, attach a managed database, or move to a multi-service spec with internal ports if you introduce background workers or APIs. If you later need deeper customization—WebSockets, custom reverse proxy rules, or non-HTTP protocols—graduate to Droplets or Kubernetes while keeping the Qwik server and Dockerfile exactly as above as a portable unit. The deployment surface stays small; most changes are infrastructure concerns, not Qwik concerns.