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)