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
Build options
pyxle build --out-dir ./output # Custom output directory
pyxle build --incremental # Reuse cached artifacts
pyxle build --config ./custom.json # Custom config fileServe in production
pyxle serveThis 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
location /client/ {
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.
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 -
pyxle buildcompletes successfully - Node.js is
>= 20.19on the server'sPATH - 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 - Add
.env.localto.gitignore
Next steps
- Full CLI reference: CLI Commands
- Full config reference: Configuration