Deploy recipes¶
Deployment covers what ships (the Docker stack, the share tunnel, SQLite vs PostgreSQL). This page answers where: copy-paste starting points for the hosts people actually use. The template stays host-neutral on purpose — everything below is plain Docker and static files, so none of it locks you in.
Commands use the default names (opk, openproductkit); substitute your
generated CLI and slug.
Before anything goes public¶
- Turn accounts on.
APP_AUTH_ENABLED=true— a public URL without it means anyone can use the app and its admin surface. The first visit creates the admin account; see Auth. - Keep runtime installs off.
APP_MARKETPLACE_ALLOW_INSTALLdefaults tofalse; leave it that way on shared hosts. - Secrets live in the host's env store, never in the image. The
.dockerignorealready refuses.envand license keys. - TLS at the edge. Every recipe below terminates TLS for you except the bare VPS, where your reverse proxy does it.
One image for the whole product¶
The backend serves the built web UI at / whenever apps/frontend/dist
exists, so a single container can deliver everything. The stack's images split
web and API for nginx; for single-container hosts (Fly, Railway, Cloud Run),
add this Dockerfile.single next to the compose file:
# Dockerfile.single — web build + API in one image.
FROM node:22-alpine AS web
RUN corepack enable
WORKDIR /app
COPY . .
RUN pnpm install && pnpm -C apps/frontend build
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
COPY . .
# The web build is produced in the stage above because .dockerignore
# (correctly) keeps your local apps/frontend/dist out of the build context.
COPY --from=web /app/apps/frontend/dist ./apps/frontend/dist
RUN uv sync --no-dev
ENV APP_DATABASE_URL=sqlite:////app/data/app.db
EXPOSE 8000
CMD ["uv", "run", "--no-sync", "python", "-m", "openproductkit_backend"]
Smoke test it locally:
docker build -f Dockerfile.single -t myapp .
docker run --rm -p 8000:8000 -v myapp-data:/app/data myapp
# http://localhost:8000 serves the UI, /api/health the API
SQLite needs a disk that survives restarts
On any host with an ephemeral filesystem, either mount a volume for
/app/data (Fly, Railway) or use PostgreSQL (APP_DATABASE_URL). Cloud
Run has no volumes — use a managed PostgreSQL there.
Fly.io¶
The shortest path from repo to URL with a persistent disk:
Edit fly.toml to use the single-container image and the volume:
[build]
dockerfile = "Dockerfile.single"
[http_service]
internal_port = 8000
force_https = true
[mounts]
source = "data"
destination = "/app/data"
Then secrets and ship:
For PostgreSQL instead of the volume: fly postgres create, attach it, and set
APP_DATABASE_URL accordingly (the backend image includes psycopg when you
generated with the PostgreSQL answer).
Railway¶
Point Railway at the repo and tell it which Dockerfile to use:
- New Project → Deploy from GitHub repo.
- In the service's settings set the variable
RAILWAY_DOCKERFILE_PATH=Dockerfile.single. - Add a volume mounted at
/app/data(or provision Railway PostgreSQL and setAPP_DATABASE_URL). - Add
APP_AUTH_ENABLED=trueand yourAPP_LICENSE_PUBLIC_KEYunder Variables, then generate a domain under Networking.
Google Cloud Run (+ Firebase Hosting)¶
Cloud Run runs the same single-container image; Firebase Hosting is only worth adding if you want its CDN in front of the static files.
gcloud run deploy myapp \
--source . \
--set-env-vars APP_AUTH_ENABLED=true,APP_LICENSE_PUBLIC_KEY=<key>,APP_DATABASE_URL=<postgres url>
(--source builds with your Dockerfile — temporarily name Dockerfile.single
as Dockerfile, or build/push the image yourself.) Cloud Run's filesystem is
ephemeral: SQLite does not fit here — use Cloud SQL or any managed
PostgreSQL.
To put Firebase Hosting in front, rewrite everything to the service and let the container do the serving:
{
"hosting": {
"public": "apps/frontend/dist",
"rewrites": [
{ "source": "/api/**", "run": { "serviceId": "myapp", "region": "us-central1" } },
{ "source": "**", "destination": "/index.html" }
]
}
}
Build the frontend first (opk build web), then firebase deploy. The static
files come from the CDN; /api hits Cloud Run same-origin, so no CORS
configuration is needed.
Static host for the frontend only (Vercel, Cloudflare Pages, Netlify)¶
The frontend is a plain Vite build (opk build web → apps/frontend/dist),
so any static host can serve it — as long as it proxies /api to your
backend so requests stay same-origin:
vercel.json in the frontend root:
Host the backend anywhere from the recipes above. Prefer the proxy over CORS: one origin means the bearer session, cookies-free auth and the generated client all work unchanged.
A VPS you own¶
Already first-class: copy the repo, put your real .env next to it, and
opk stack up (see Deployment). Put
Caddy in front for TLS:
Cloudflare named tunnel (self-host without opening ports)¶
opk stack share gives throwaway URLs; a named tunnel is the permanent
version — a stable domain pointed at a box behind NAT, no inbound ports:
cloudflared tunnel login
cloudflared tunnel create myapp
cloudflared tunnel route dns myapp app.your-domain.com
cloudflared tunnel run --url http://localhost:8080 myapp
Run the stack (opk stack up), and app.your-domain.com serves it with TLS.
Remember: a stable public URL is exactly the case for APP_AUTH_ENABLED=true.