Zero-Downtime Upgrades
bext-server supports graceful binary upgrades with zero connection drops. The running process hands its listening sockets to a new process, drains in-flight requests, and exits — all without a single failed request.
Quick Start
# 1. Install the new binary (atomic replace)
curl -fsSL https://bext.dev/install | sh
# 2. Trigger zero-downtime upgrade
bext upgrade
That's it. The old process finishes serving all in-flight requests while the new process immediately starts accepting new connections on the same port.
How It Works
The upgrade uses socket FD inheritance — the same technique nginx uses for binary upgrades.
Operator Old process New process
│ │ │
├── bext upgrade ──────────►│ │
│ (sends SIGUSR2) │ │
│ ├── fork+exec ────────────►│
│ │ BEXT_UPGRADE_FDS=3,4 │
│ │ BEXT_UPGRADE_OLD_PID=X │
│ │ ├── bind inherited FDs
│ │ ├── start accepting
│ │◄── SIGQUIT ──────────────┤ (ready)
│ ├── stop accepting │
│ ├── drain 30s │
│ ├── exit(0) │
│ × │
Step by step
1. SIGUSR2 is sent to the running bext-server (via bext upgrade or kill -USR2)
2. The running process forks and exec's the new binary at its own path
3. Listening socket file descriptors are passed via the BEXT_UPGRADE_FDS environment variable
4. The new process binds to the inherited FDs — no port conflict, no EADDRINUSE
5. Once the new process is ready, it sends SIGQUIT to the old process
6. The old process stops accepting new connections and drains in-flight requests (30s timeout)
7. The old process exits cleanly
During steps 4-6, both processes are serving requests simultaneously — there is never a moment where no process is listening.
CLI Reference
bext upgrade
Sends SIGUSR2 to the running bext-server process.
# Basic: re-exec current binary (e.g., after curl install)
bext upgrade
# Replace binary first, then upgrade
bext upgrade --binary /path/to/new-bext-server
# Specify PID file location
bext upgrade --pid-file /run/bext.pid
Flags:
| Flag | Default | Description |
|---|---|---|
--binary |
(none) | Path to new binary. Atomically replaces the running binary before sending SIGUSR2. |
--pid-file |
Auto-detect | Path to PID file. Searches /run/bext.pid, /run/nginx.pid, /tmp/bext.pid. |
Manual signal
If you prefer manual control:
# Find the PID
cat /run/bext.pid
# Send upgrade signal
kill -USR2 $(cat /run/bext.pid)
Systemd Integration
bext's upgrade works seamlessly with systemd. The service file doesn't need any changes — bext upgrade handles everything:
# /etc/systemd/system/bext.service
[Unit]
Description=bext application server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/bext-server run
ExecReload=/usr/local/bin/bext-server upgrade
Restart=on-failure
PIDFile=/run/bext.pid
[Install]
WantedBy=multi-user.target
With this setup, systemctl reload bext triggers a zero-downtime upgrade.
Environment Variables
These are set automatically during upgrade — you don't need to set them manually.
| Variable | Description |
|---|---|
BEXT_UPGRADE_FDS |
Comma-separated list of inherited socket file descriptors |
BEXT_UPGRADE_OLD_PID |
PID of the parent process to signal when ready |
Signals Reference
| Signal | Effect |
|---|---|
SIGUSR2 |
Start zero-downtime upgrade (fork+exec new binary) |
SIGQUIT |
Graceful shutdown with 30s drain (sent by new process) |
SIGHUP |
Hot-reload V8 bundle and clear caches (no binary change) |
SIGTERM |
Immediate shutdown |
Drain Timeout
The old process waits 30 seconds for in-flight requests to complete. Requests that take longer are terminated. This timeout covers:
- Long-running SSR renders
- WebSocket connections (gracefully closed)
- SSE event streams (final event + close)
- Pending PHP worker requests
Cache Behavior During Upgrade
During upgrade, the new process starts with cold caches (ISR, tenant, compression). This means:
- First few requests after upgrade may be slower (cache miss → render → cache fill)
- The ISR stale-while-revalidate mechanism ensures users still see cached content while caches warm
- Tenant cache is populated on first request per hostname
- Compression cache fills within seconds under load
If you're running with Redis L2 cache enabled, cached content survives the upgrade since Redis is external.
Automating Upgrades
Cron-based auto-update
# /etc/cron.d/bext-upgrade
0 3 * * * root curl -fsSL https://bext.dev/install | sh && bext upgrade
CI/CD pipeline
# .github/workflows/upgrade.yml
- name: Upgrade bext
run: |
ssh deploy@server 'curl -fsSL https://bext.dev/install | sh && bext upgrade'
Webhook-triggered
Use bext's git deploy webhook to trigger upgrades on push:
# bext.config.toml
[git_deploy]
enabled = true
secret = "your-webhook-secret"
on_push = "curl -fsSL https://bext.dev/install | sh && bext upgrade"
Troubleshooting
Upgrade failed: "cannot resolve own binary"
The process can't find its own executable path. This happens in some containerized environments. Set the binary path explicitly:
bext upgrade --binary /usr/local/bin/bext-server
Upgrade failed: "failed to spawn new process"
Check file permissions on the binary:
ls -la $(which bext-server)
# Should be: -rwxr-xr-x
Old process didn't exit after 30s
The drain timeout expired with requests still in flight. Check for:
- Very long-running requests (increase timeout if needed)
- WebSocket connections that didn't close
- Stuck PHP worker processes
Port conflict on startup
If you see EADDRINUSE during upgrade, the FD inheritance failed. This can happen if:
- The binary was compiled without the upgrade module
- The BEXT_UPGRADE_FDS env var was lost (check your process supervisor)