Deployment
Deploy a Pyxle application with pyxle build to compile assets and pyxle serve to run in production.
Build for production
pyxle buildThis:
- Compiles all
.pyxlfiles into Python and JSX modules - Runs a Vite production build — bundling JS and compiling every imported stylesheet (plain CSS, CSS Modules, and Tailwind v4 via the
@tailwindcss/viteplugin when enabled) into content-hashed assets - Outputs production artifacts to the
dist/directory
pyxle buildrequires Node.js 20.19+ and npm, plus the project'spackage.jsonand its installed dependencies — step 2 runs Vite throughnpx. This bites most often in a container or CI image: Debian and Ubuntu'snodejspackage does not include npm (apt install npmas well), and some slim base images shipnodealone. Without them the build stops with a non-zero exit rather than producing adist/thatpyxle servecannot start on. Serving the result needs Node.js too, but not npm or Vite — see What to ship.
Build options
pyxle build --out-dir ./output # Custom output directory
pyxle build --incremental # Reuse cached artifacts
pyxle build --config ./custom.json # Custom config fileWhat to ship
dist/ is self-contained: it holds every compiled module, every bundled asset,
the routing metadata pyxle serve needs, and a copy of the source files the
running server reads. Ship it alongside your pyxle.config.json, your
node_modules (server-side rendering runs React on Node) and your Python
dependencies, and pyxle serve --skip-build will run it.
A minimal deployment is therefore:
dist/ # everything pyxle build produced
pyxle.config.json # your configuration
package.json # so Node resolves react / react-dom
node_modules/ # server-side rendering runs React on Node — and esbuildInstalling with
--omit=devis not enough on its own. Server rendering loads esbuild from your project at request time. Before 0.9.0 the scaffold did not declare it — it arrived only transitively through Vite, a devDependency — so a production-only install produced a server that started cleanly and then returned a 500 for every page, with the real cause visible only in the log. 0.9.0's scaffold listsesbuildunderdependencies; if you generated your project with an earlier version, add it.
plus your Python dependencies and your environment variables — including
PYXLE_SECRET_KEY, which pyxle serve requires. .env files are not part
of dist/: secrets belong in the environment, not in a build artifact.
Inside dist/:
| Path | What it holds |
|---|---|
server/ |
Compiled Python for every page and API route |
client/dist/ |
Vite output — hashed JS and CSS, served under /client/dist. It is the only part of client/ that is served; the rest is the input Vite bundled |
metadata/, meta.json, page-manifest.json |
Routing metadata |
public/ |
A copy of your public/ directory |
app/ |
A copy of the source files the server reads at runtime |
prerendered/ |
Pre-rendered pages, when you build with --static |
dist/app/ is there because compiling pages/ produces one module per route
and nothing else, while a running server still reads plain source files:
- Colocated Python helpers.
pages/api/_shared.py,pages/api/__init__.py, anything underpages/api/_internal/, and non-route modules beside your pages such aspages/s/[slug]/queries.pyare deliberately not routes — so nothing compiles them, and the compiled route that saysfrom pages.api._shared import …needs the module itself at import time. - AI-accessibility files.
pages/**/llms.pyhandlers and colocatedpages/**/*.mdfiles are read per request — see AI accessibility. - Global stylesheets.
styling.globalStylesentries are read and inlined into every rendered document.
Because they travel with the build, a deployment that ships only dist/ — a
Docker COPY --from=build /app/dist ./dist, a CI artifact, an rsync of the
build output — boots and serves exactly like one that carries the whole
repository. When your source tree is deployed it stays authoritative; the copy
is only a fallback, so editing a helper and rebuilding never picks up a stale
one.
What dist/ cannot carry is application code outside pages/: a
project-root db.py, a middleware.py named in pyxle.config.json, a local
package your loaders import. Where that import graph ends is your application's
business, not the framework's, so deploy those files the way you deploy the rest
of your code (in a Dockerfile, copy your Python sources alongside dist/).
Third-party packages come from your Python dependencies as usual.
.pyxle-build/ is an intermediate directory — a build cache for
pyxle dev and for the next pyxle build. It is not part of a deployment, and
pyxle serve does not read routing from it. That matters beyond disk space: it
is a cache, so anything that recompiles a page into it — a test helper, an
editor tool, an interrupted dev server — leaves it disagreeing with the dist/
you built. Serving reads dist/, so those disagreements cannot reach
production.
Serve in production
pyxle servepyxle serve rebuilds before serving by default, so you can never serve a
stale dist/ by accident. On a deploy target that already has a built dist/ —
the usual case — skip that step:
pyxle serve --skip-buildThat skips the Vite build (no npx needed on the deploy host) and starts in
about two seconds instead of rebuilding. Node.js is still required:
server-side rendering runs React on Node, so keep shipping node_modules as
described under What to ship. Without Node on PATH the server
refuses to start rather than serving broken pages.
This starts a production Starlette server without Vite (static assets are served directly):
pyxle serve --host 0.0.0.0 --port 8000In production mode (debug=false) the server also compresses responses larger
than 500 bytes with gzip automatically — no reverse-proxy configuration needed
for that. The gzip middleware is streaming-aware (it flushes the compressor
per chunk), so streaming SSR still delivers the shell first
behind gzip in production rather than buffering the whole response.
Serve options
| Flag | Default | Description |
|---|---|---|
--host |
127.0.0.1 |
Bind address |
--port |
8000 |
Port number |
--workers / -w |
1 |
Server worker processes — one per CPU core (multi-core) |
--ssr-workers |
auto |
Persistent Node.js SSR processes, per server worker (0/auto = size to CPU cores, capped at 4). pyxle serve defaults to auto; pass a number to pin it |
--dist-dir |
dist/ |
Directory with production artifacts |
--skip-build |
false |
Skip running build first |
--serve-static/--no-serve-static |
true |
Serve static assets directly (disable when a CDN hosts them) |
--config |
pyxle.config.json |
Path to an alternate config file |
Build + serve in one step
By default, pyxle serve runs pyxle build first. Skip this with --skip-build:
# Build once, serve multiple times
pyxle build
pyxle serve --skip-buildMulti-core (worker processes)
By default pyxle serve runs a single async server process, which uses one
CPU core. To use every core on a multi-core server, run one server worker
process per core with --workers (requires Pyxle 0.4.3+):
pyxle serve --workers $(nproc) # explicit
pyxle serve --workers 0 # auto-detect from CPU cores (one per core)--workers 0 auto-detects the core count, so you don't have to hard-code it for
the target host. Each worker is an independent server process with its own SSR worker pool, all
sharing one listening socket — incoming connections are balanced across them
with no load balancer and no shared state to configure. Throughput on
CPU-bound endpoints scales near-linearly with the worker count.
pyxle serve builds the project once before the workers start, so the build is
never duplicated. Combine --workers with --skip-build only when dist/
already exists. Workers reconstruct their configuration from PYXLE_SERVE_*
environment variables exported by the parent process — these are internal;
don't set them yourself.
SSR workers
Server-side rendering runs in persistent Node.js processes that stay warm
between requests. pyxle serve auto-sizes the pool by default — the pool
grows to the machine's CPU count, capped at 4, per server worker — so you don't
have to think about it for most deployments:
pyxle serve # auto-sized SSR pool (default)
pyxle serve --ssr-workers 2 # pin the pool to exactly 2 processes per server worker--ssr-workers applies per server worker, so the total number of Node.js
render processes is workers × ssr-workers. Passing 0 (or omitting the flag)
auto-sizes the pool to the machine's CPU count (capped at 4) per server worker.
Concurrency within a worker
Each SSR worker handles many renders concurrently, not one at a time. This
matters most for streaming SSR: a streaming render spends almost
all of its wall-clock time idle, awaiting loader promises and <Suspense>
boundaries. The worker interleaves those idle windows, so overlapping requests
to a streaming page all start rendering immediately instead of queueing behind
each other. Per-request state (pathname, CSRF token, <Head> tags, styles) is
isolated per render, so interleaving never mixes one visitor's page into
another's.
The in-worker concurrency cap defaults to 16 in-flight renders and is tunable
with the PYXLE_SSR_WORKER_CONCURRENCY environment variable — raise it only for
a workload dominated by slow, I/O-bound loaders where renders sit idle waiting
on the network. Because the cap governs idle concurrency, adding SSR
processes (--ssr-workers) is what adds CPU-parallel render throughput.
Sizing guidance
| Workload | Suggestion |
|---|---|
| API-heavy, little SSR | --workers $(nproc) |
| SSR-heavy / streaming pages | Defaults are fine — the auto-sized pool renders concurrently. Raise --ssr-workers only if CPU-bound renders queue |
| Small VPS (1–2 cores) | Defaults (--workers 1, auto SSR pool) are fine |
| Memory-constrained | Each worker is a full process — reduce --workers before reducing --ssr-workers |
Per-worker state (multi-worker caveats)
Each worker is a separate process with no shared memory, which is what makes multi-core serving trivial to operate — there's nothing to coordinate. The trade-off is that any in-process state is per-worker:
| Feature | Per-worker behaviour | For cross-worker behaviour |
|---|---|---|
| Page cache | Each worker has its own in-memory cache | Use the Redis backend (PYXLE_PAGE_CACHE_BACKEND=redis) — shared across workers and hosts |
| WebSocket pub/sub | Messages reach only clients on the same worker | Use the Redis broker (PYXLE_REALTIME_BROKER=redis) — relays channels across workers and hosts |
| Metrics | /api/__pyxle/metrics (opt-in via metricsEndpoint: true) reports that worker's numbers (with a worker label) |
Aggregate at the Prometheus scraper |
| Background tasks | pyxle.tasks queue is per-worker |
Use a real job queue (Celery / ARQ / Dramatiq) |
This is by design: Pyxle keeps the default path stateless so workers fork cleanly and scale linearly. The escape hatch is always a shared backend (Redis for cache/pub-sub) or an external service (a job queue, a Prometheus scraper), not a shared in-process resource — so the same code runs at one worker or fifty.
Why not a single shared SSR pool across workers? A shared Node pool over a Unix socket would save some memory but add a coordination point and a single contention bottleneck, undermining the share-nothing model. Per-worker pools stay simple and isolated — a crashed render can't affect another worker. Size with
--ssr-workersinstead.
Rolling deploys & graceful restart
For zero-downtime deploys, don't restart Pyxle in place — drain and replace:
- Behind a load balancer / reverse proxy: start a new instance (new
pyxle serve) on a second port, wait for/readyzto return200(see Observability → health probes), shift traffic to it, then stop the old one. This gives true zero-downtime. - Single host, process manager: run
pyxle serveunder systemd (or supervisor). Asystemctl restartcleanly stops the old workers (uvicorn handlesSIGTERM, finishing in-flight requests) and starts new ones — a sub-second blip, fine for most apps. Point your readiness probe at/readyzso the proxy only routes once a worker is actually ready. Blue-green (option 1) is the recommended zero-downtime path — and it sidesteps the question of preloading entirely.
Blue-green gotcha — pin the CSRF cookie name and share the secret. The two instances run on different ports, and by default the CSRF cookie is named
pyxle-csrf-<port>(per-port, so multiple apps on one host don't collide). In a blue-green cutover that backfires: a token the blue instance issued aspyxle-csrf-8000is rejected by green onpyxle-csrf-8001, so mid-shift POSTs403. Pin an explicit, shared name so both instances agree:{ "csrf": { "cookieName": "pyxle-csrf" } }Both instances must also share the same
PYXLE_SECRET_KEY— a token signed by one is only valid on the other if the signing key matches.
A note on preloading.
pyxle serveimports each worker's app after forking (uvicorn's model), so there's no copy-on-write sharing of read-only state between workers. In practice this rarely matters — Python's per-process memory is dominated by the interpreter and the SSR Node pool, not your page modules. If you specifically need import-once-then-fork, run the app under a process manager that supports it (e.g. gunicorn with--worker-class uvicorn.workers.UvicornWorker --preload), pointing at thepyxle.build.production:create_appfactory withPYXLE_SERVE_PROJECT_ROOTset to your project directory — gunicorn then owns worker lifecycle and graceful rolling restarts. This is an advanced setup; the blue-green approach above is simpler and gives true zero-downtime.
Environment configuration
Set production settings via environment variables:
export PYXLE_HOST=0.0.0.0
export PYXLE_PORT=8000
export PYXLE_DEBUG=false
export PYXLE_SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")
pyxle serve --skip-build
PYXLE_SECRET_KEYis required in production. It signs CSRF tokens and any signed cookies (sessions, unsubscribe links).pyxle serverefuses to start without it (unless you've setcsrf.enabled=false). Generate a long random value once and keep it stable — store it in your process manager's environment or a secrets manager, never in the repo. Rotating it invalidates outstanding tokens and signed links.
Or in a .env.production file:
PYXLE_HOST=0.0.0.0
PYXLE_PORT=8000
PYXLE_DEBUG=false
PYXLE_PUBLIC_API_URL=https://api.production.com| Variable | Overrides |
|---|---|
PYXLE_SECRET_KEY |
Signing secret for CSRF tokens and signed cookies (required in production) |
PYXLE_HOST |
Bind address |
PYXLE_PORT |
Port number |
PYXLE_DEBUG |
Debug mode (true/1 or false/0) |
PYXLE_PAGES_DIR |
Pages directory |
PYXLE_PUBLIC_DIR |
Static assets directory |
PYXLE_BUILD_DIR |
Build output directory |
CLI flags win over environment variables, which win over pyxle.config.json.
Variables prefixed PYXLE_PUBLIC_ are exposed to client code — never put
secrets in them.
Reverse proxy setup
In production, place Pyxle behind a reverse proxy (Nginx, Caddy, etc.) for TLS termination, load balancing, and static asset caching.
Nginx example
# Maps the Upgrade header so a WebSocket request sends "Connection: upgrade"
# while an ordinary request sends "Connection: close".
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required if any page exposes a `websocket` handler / uses useWebSocket.
# Without these, nginx proxies the wss:// handshake as a plain GET and the
# connection silently never upgrades (the client just sees the page HTML).
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # keep long-lived WebSockets from idling out
}
# Cache static assets. Everything Pyxle serves here is a content-hashed
# bundle, so a one-year immutable cache is safe.
location /client/dist/ {
proxy_pass http://127.0.0.1:8000;
expires 1y;
add_header Cache-Control "public, immutable";
}
}WebSocket apps: the
proxy_http_version 1.1+Upgrade/Connectionlines above are what letwss://connections through. A reverse proxy that omits them serves the page fine but every WebSocket silently fails to connect. (Browsers do the WebSocket handshake over HTTP/1.1 even when the listener also speaks HTTP/2 — that's expected; the headers above handle it.)
Caddy example
example.com {
reverse_proxy localhost:8000
}Caddy's reverse_proxy upgrades WebSocket connections automatically — no extra
configuration is needed.
CDN and edge caching
Pages that render the same HTML for everyone -- a landing page, docs, marketing
routes -- don't need to hit your origin on every request. Declare them in the
cache block and Pyxle serves them
Cache-Control: public, s-maxage=<seconds> (with a stale-while-revalidate
window) so a CDN or reverse proxy absorbs the load. This is what lets a small
origin survive a traffic spike.
{
"cache": {
"/": 60,
"/docs/*": 300
},
"csrf": {
"exemptPaths": ["/api/__actions/"]
}
}Two things to know before relying on it:
A cacheable response carries no CSRF cookie, because a shared cache must never replay one user's token to another (and most CDNs won't cache a response that sets a cookie). Any
@actionreachable from a cached route must therefore be CSRF-exempt -- hence thecsrf.exemptPathsabove. Only cache routes that render no per-user state and whose actions are safe to exempt.The headers make a response eligible; the CDN still has to opt in. Many CDNs don't cache HTML by default:
- Cloudflare — add a Cache Rule (or Page Rule) with Cache Everything
for the cached paths. Cloudflare honors
s-maxagefor the edge TTL once the rule is in place. (Cloudflare ignoresVaryheaders other thanAccept-Encoding; Pyxle's SPA navigation accounts for this and falls back to a normal full-page load if the edge ever serves cached HTML to an in-app navigation request, so nothing breaks.) - Nginx / Caddy / Varnish — enable
proxy_cache(or the equivalent) for those routes; they respects-maxageout of the box.
- Cloudflare — add a Cache Rule (or Page Rule) with Cache Everything
for the cached paths. Cloudflare honors
Content-hashed client bundles (under /client/dist/assets/) are already sent
Cache-Control: public, max-age=31536000, immutable, and other static files get
public, max-age=3600 -- independent of the cache block above, which governs
page responses.
Docker
FROM python:3.12-slim
# Install Node.js — Pyxle requires Node.js >= 20.19 (Vite 7). The 22.x LTS
# line satisfies that; the older setup_20.x stream can ship 20.16–20.18, which
# Vite 7 rejects at startup, so use 22.x (or a 20.x image pinned to >= 20.19).
RUN apt-get update && apt-get install -y curl \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir pyxle-framework
# Install Node dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# Copy application code
COPY . .
# Build for production
RUN pyxle build
# Run
EXPOSE 8000
CMD ["pyxle", "serve", "--host", "0.0.0.0", "--skip-build"]Match --workers to the container's CPU allocation — e.g.
CMD ["pyxle", "serve", "--host", "0.0.0.0", "--skip-build", "--workers", "4"]
for a 4-vCPU container. If you scale by running more single-worker containers
behind a load balancer instead, keep the default.
Multi-stage builds
Building in one stage and copying the result into a clean runtime image works
too, because dist/ carries everything the server reads (see
What to ship):
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY pyxle.config.json package.json ./
CMD ["pyxle", "serve", "--host", "0.0.0.0", "--skip-build"]Add your own Python modules that live outside pages/ — a db.py, a
middleware.py referenced from pyxle.config.json — to that copy list.
Health checks
The scaffold includes a health endpoint at /api/pulse:
curl http://localhost:8000/api/pulse
# {"status": "ok", ...}Use this for load balancer health checks and monitoring.
Process management (systemd)
On a VM, run pyxle serve under a process supervisor so it restarts on
failure and starts on boot:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Pyxle app
After=network.target
[Service]
User=app
WorkingDirectory=/srv/myapp
Environment=PYXLE_DEBUG=false
# Keep the secret out of the unit file — point at an environment file that is
# root-owned, chmod 600, and NOT in your repo.
EnvironmentFile=/etc/myapp/pyxle.env # contains PYXLE_SECRET_KEY=...
ExecStart=/srv/myapp/.venv/bin/pyxle serve --skip-build --workers 4 \
--host 127.0.0.1 --port 8000
Restart=always
[Install]
WantedBy=multi-user.targetBuild during deployment (pyxle build), then systemctl restart myapp —
with --skip-build the restart is fast because the unit only boots the
server. Node.js must be on the service's PATH for SSR.
Database migrations
If your app uses pyxle-db, schema changes live as
checksum-tracked files in migrations/ and are applied automatically at
startup — so a plain deploy (build, restart) also migrates. For production you
usually want migrations to be an explicit, observable deploy step instead of
a side effect of the first request:
pyxle-db migrate # apply pending migrations, then start the server
pyxle serve --skip-buildGuidelines:
- Run migrations once per deploy, before starting the new server — not per worker. Each migration is applied exactly once and atomically, so a race is safe, but running it up front keeps the outcome visible in your deploy logs.
- Never edit an already-applied migration — the checksum tracker rejects it. Add a new migration file instead.
- For zero-downtime (blue-green) deploys, keep migrations backward-compatible with the currently-running version (expand, then contract): add columns/tables in one release, backfill, and only drop the old shape in a later release once no instance references it. A destructive migration applied while the old instance is still serving will break it mid-cutover.
Using the ORM path? Drive Alembic as your migration step instead — pick one migration tool per app. See pyxle-db → Migrations.
Checklist
Before deploying:
-
pyxle checkpasses with no errors (it exits0on warnings alone — an unused import will not block you; an unresolved reference in the Python half, or an@actionasking for a body parameter it never annotated, will). It does not resolve identifiers in the JSX half, so it is not a substitute for opening the page — do that too. -
pyxle buildcompletes successfully (it exits non-zero if it cannot run Vite — never ship adist/from a build that failed) - Node.js
>= 20.19and npm are on the build machine'sPATH - Node.js is
>= 20.19on the server'sPATH -
node_modulesis installed on the server too — copypackage.jsonandpackage-lock.jsonnext todist/and runnpm cithere. Server-side rendering runs React through Node at request time and loadsesbuildfrom your project, so adist/deployed without them answers 500 on every page. The errors name one missing package at a time, so installing them individually goes in circles;npm ciis the fix. (The Dockerfile below already does this.) - Set
PYXLE_DEBUG=falsein production - Set
PYXLE_SECRET_KEYto a long random value (required —pyxle servewon't start without it) - Apply database migrations, if you use
pyxle-db(see below) - Size
--workersto the machine's CPU cores (multi-core serving) - Configure CSRF
cookieSecure: trueif using HTTPS - Pin a shared
csrf.cookieNameandPYXLE_SECRET_KEYacross instances for blue-green deploys - Add CORS origins if serving APIs to other domains
- Set up a reverse proxy for TLS
- Declare publicly-cacheable routes in the
cacheblock (and opt your CDN in) - Configure health check monitoring on
/api/pulse - Configure Python logging if you rely on your own
log.infocalls —pyxle servedoes not, soINFOrecords are dropped whileWARNINGand above still reach stderr (Debugging) - Add
.env.localto.gitignore
Next steps
- Full CLI reference: CLI Commands
- Full config reference: Configuration