Git-based preview deployment proxy server
Project description
Preview Server
A Python ASGI web application that proxies incoming requests to ephemeral, per-ref sub-servers from a git repository. The system manages the lifecycle of sub-servers, automatically scaling down idle instances while maintaining request history and detailed status information.
Security Warning
If you use this server with a GitHub repository that accepts Pull Requests, be aware of the security implications:
An attacker could submit a PR containing malicious code. Even if the PR is never merged, the commit exists in your repository. If an attacker can craft a URL with that commit hash, they could execute arbitrary code on your server.
To mitigate this risk, enable hostname signing:
# Generate a secret and use it for signing
preview-server ~/my-repo --secret "your-secret-here"
# Generate signed URLs for specific refs
preview-server --sign main --secret "your-secret-here"
# Outputs: main--a1b2c3d4e5f6a7b8c9d0
# Only signed hostnames will be accepted
# http://main--a1b2c3d4e5f6a7b8c9d0.localhost:8000/ - works
# http://main.localhost:8000/ - rejected with 403
With signing enabled, only pre-authorized hostnames can access the server, preventing attackers from triggering arbitrary commits.
Features
- Per-ref Preview Servers: Automatic sub-server creation for git branches, tags, and commits
- Scale-to-Zero: Idle servers are automatically terminated after a configurable TTL
- Auto-Pull: Automatically pull latest branch changes when not accessed recently
- Signed Hostnames: Cryptographic signing to restrict access to pre-authorized refs only
- Request Streaming: Proxies streaming responses and WebSocket connections without buffering
- Status Monitoring: Comprehensive
/-/preview-serverendpoint with server and request metrics - Basic Auth: Optional HTTP Basic authentication protecting all endpoints
- Graceful Startup: Requests are queued during sub-server initialization
- Resilient: Auto-restart sub-servers on crash (up to 3 attempts)
Installation
uv tool install preview-server
Or run it directly using uvx:
uvx preview-server --help
Usage
# Using the CLI command (recommended)
preview-server /path/to/repo [OPTIONS]
# Or with uvx
uvx preview-server /path/to/repo [OPTIONS]
# Or as a module
python -m preview_server.cli /path/to/repo [OPTIONS]
Options
-p, --port PORT: Server port (default: 8000)--idle-ttl DURATION: Idle timeout before terminating sub-server (default: 5m)- Format:
5m,10s,2h, etc.
- Format:
--auto-pull DURATION: Auto-pull branches if not requested within this duration (disabled by default)- Only affects branches (tags and commits are considered immutable)
- Format:
5m,10s,2h, etc.
--basic-auth USER:PASS: Basic auth credentials (optional)--secret SECRET: Signing secret for hostname verification (optional)- When set, only signed hostnames are accepted
- See Hostname Signing for details
--sign HOSTNAME: Sign a hostname and print the result (requires --secret)--log-file PATH: JSON logs output file (default: stderr)-c, --config PATH: Path to TOML configuration file (optional)--cleanup: Remove all cached worktrees and repos, then exit--cleanup-yes: Same as--cleanupbut skip the confirmation prompt--admin-secret SECRET: Secret for admin API access (optional)- Enables the management API at
/-/preview-server/repos/* - Allows starting the server with no repos configured
- Forces multi-repo mode even with a single repo
- Enables the management API at
--persist-repos PATH: JSON file path for persisting repo configuration (optional)- When set, repo changes made via the admin API are saved to this file
- On startup, repos are loaded from this file if it exists
Examples
# Start server on port 8000, default 5-minute idle timeout
preview-server ~/my-repo
# Use different port and idle timeout
preview-server ~/my-repo -p 3000 --idle-ttl 10m
# Enable basic auth
preview-server ~/my-repo --basic-auth admin:secret
# Enable auto-pull (pull latest if branch not accessed in 5 minutes)
preview-server ~/my-repo --auto-pull 5m
# Clone from GitHub
preview-server https://github.com/user/repo
# Use a configuration file
preview-server -c config.toml
# Clean up all cached worktrees and repos
preview-server --cleanup
Cleanup
The preview server caches cloned repositories and worktrees in ~/.cache/preview-server/. Over time, this can accumulate significant disk space.
Use the --cleanup flag to remove all cached data:
preview-server --cleanup
This will:
- Show a summary of what will be deleted (dry run)
- Display the total disk space to be freed
- Ask for confirmation before proceeding
- Remove all worktrees and cached repos
Use --cleanup-yes to skip the confirmation prompt (useful for scripts):
preview-server --cleanup-yes
Example output:
Cleaning up preview-server cache at /home/user/.cache/preview-server
Worktrees (3):
- /home/user/.cache/preview-server/worktrees/main (1.0 KB)
- /home/user/.cache/preview-server/worktrees/develop (1.4 KB)
- /home/user/.cache/preview-server/worktrees/feature (1.6 KB)
Cached repos (1):
- /home/user/.cache/preview-server/repos/my-project (33.4 KB)
Total: 37.4 KB
Continue? [y/N] y
Cleanup complete.
Auto-Pull
The auto-pull feature keeps branch previews up-to-date by automatically pulling latest changes when a branch hasn't been accessed recently. This is useful for long-running preview servers where branches may receive updates.
How It Works
- When a request comes in for a branch that's already running
- If the branch hasn't been requested within the
--auto-pullduration - The server fetches and resets the worktree to
origin/<branch> - The request waits for the pull to complete before being proxied
Key Details
- Only affects branches: Tags and commits are considered immutable and are never auto-pulled
- Disabled by default: You must explicitly enable with
--auto-pull DURATION - Request waits: The pull happens synchronously before the request is proxied
- 30-second timeout: Pull operations timeout after 30 seconds to prevent blocking
- Handles force pushes: Uses
git fetch+git reset --hardto handle all updates
Example
# Pull latest if branch not accessed in 5 minutes
preview-server ~/my-repo --auto-pull 5m
# Combined with idle timeout (common pattern)
# - Auto-pull after 5 minutes of inactivity
# - Terminate server after 30 minutes of inactivity
preview-server ~/my-repo --auto-pull 5m --idle-ttl 30m
Hostname Signing
Hostname signing restricts which git refs can be previewed by requiring a cryptographic signature in the hostname. This is essential when running against repositories that accept untrusted contributions (like GitHub repos with PRs).
How It Works
- Generate a secret and start the server with
--secret - Use
--signto generate signed hostnames for authorized refs - Only requests with valid signatures are accepted
Generating Signed Hostnames
# Generate a signed hostname
preview-server --sign main --secret "your-secret-here"
# Output: main--a1b2c3d4e5f6a7b8c9d0
# For a specific commit
preview-server --sign "backend--a56fd34" --secret "your-secret-here"
# Output: backend--a56fd34--1234abcd5678ef90abcd
# For multi-repo projects
preview-server --sign "frontend--develop" --secret "your-secret-here"
Running with Signing Enabled
# Start server with signing secret
preview-server ~/my-repo --secret "your-secret-here"
# Valid requests (with signature)
curl http://main--a1b2c3d4e5f6a7b8c9d0.localhost:8000/
# Invalid requests (rejected with 403)
curl http://main.localhost:8000/
curl http://main--wrongsig.localhost:8000/
Signature Format
- Signatures are 20-character lowercase hex strings appended after
-- - Example:
main--a1b2c3d4e5f6a7b8c9d0 - Uses only subdomain-safe characters:
0-9anda-f - The signature is an HMAC-SHA256 truncated to 80 bits
Config File
# Enable signing via config file
secret = "your-secret-here"
Configuration File
You can use a TOML configuration file instead of (or in addition to) command-line arguments.
CLI and Config File Merging
When using both a config file and CLI arguments, they are merged together:
- CLI arguments override config file values - If both specify the same option, CLI wins
- CLI arguments add to config file - Options only in CLI are added to config settings
- Config values are preserved - Options only in config file are kept
This allows you to keep common settings in a config file and override specific options via CLI:
# Config file has repo, port, idle-ttl
# CLI adds --secret (not in config) and overrides port
preview-server -c config.toml --port 9000 --secret mysecret
Config File Format
Create a file named config.toml (or any name you prefer):
# Server port (default: 8000)
port = 8000
# Idle timeout before terminating sub-server (default: 5m)
# Format: "5m", "10s", "2h", etc.
idle-ttl = "10m"
# Auto-pull branches if not requested within this duration (disabled by default)
# Only affects branches; tags and commits are considered immutable
# Format: "5m", "10s", "2h", etc.
auto-pull = "5m"
# Basic auth credentials (optional)
# Format: "username:password"
basic-auth = "admin:secret"
# Signing secret for hostname verification (optional)
# When set, only signed hostnames are accepted
secret = "your-secret-here"
# JSON logs output file (default: stderr)
log-file = "/var/log/preview-server.log"
# Admin API secret (optional)
# Enables the management API at /-/preview-server/repos/*
admin-secret = "your-admin-secret"
# Persistence file for repo configuration (optional)
# When set, repo changes are saved to this file
persist-repos = "/var/lib/preview-server/repos.json"
# Single repo mode (backwards compatible)
repo = "/path/to/repo"
All fields are optional. Missing values use defaults or CLI arguments.
Note: This feature requires Python 3.11+ (for the tomllib standard library module).
Multi-Repo Mode
You can serve multiple repositories from a single preview server instance. There are two ways to configure multi-repo mode:
Via Command Line
Use label:path syntax for each repository:
# Multiple local repos
preview-server frontend:/path/to/frontend backend:/path/to/backend
# Multiple GitHub repos
preview-server api:https://github.com/org/api web:https://github.com/org/web
# Mix of local and remote
preview-server frontend:~/dev/frontend backend:https://github.com/org/backend
Via Config File
Use the [repos] section in your config file:
port = 8000
idle-ttl = "10m"
[repos]
frontend = "/path/to/frontend"
backend = "https://github.com/org/backend"
api = "git@github.com:org/api.git"
Hostname Format
In multi-repo mode, the hostname format changes to include the project name:
project.localhost:8000- Uses the default branch (main)project--branch.localhost:8000- Uses a specific branch
Examples
# Start with multi-repo config file
preview-server -c config.toml
# Or via command line
preview-server frontend:/path/to/frontend backend:/path/to/backend -p 8000
# Access different projects and branches:
curl http://frontend.localhost:8000/ # frontend, main branch
curl http://frontend--develop.localhost:8000/ # frontend, develop branch
curl http://backend--feature.localhost:8000/ # backend, feature branch
curl http://api.localhost:8000/ # api, main branch
The -- separator allows branch names to contain dots and other characters that would otherwise conflict with the hostname pattern.
Status Endpoints
HTML Dashboard: GET /-/preview-server
Access http://localhost:8000/-/preview-server (or your configured port) for an interactive dashboard that shows:
- Server status with color-coded indicators
- Running sub-servers in card layout
- Server details: port, PID, uptime, restart count, idle countdown
- Expandable details with command and recent logs (up to 100 lines)
- Stream logs toggle: Enable 1-second polling for live log updates (newest first)
- Responsive design with modern styling
JSON API: GET /-/preview-server.json
For programmatic access, use http://localhost:8000/-/preview-server.json to get:
{
"status": "ok",
"running_servers": 1,
"idle_ttl_seconds": 300.0,
"sub_servers": [
{
"ref": "main",
"port": 53153,
"pid": 24586,
"uptime_seconds": 120,
"restart_attempts": 0,
"command": "./server.sh",
"last_request_seconds_ago": 10.5,
"idle_ttl_seconds": 300.0,
"seconds_until_idle": 289.5,
"recent_logs": [
"[2024-12-20 10:30:45] [STARTUP] Started on port 53153",
"[2024-12-20 10:30:46] Server listening..."
]
}
]
}
Repository Configuration
Each repository must contain a server.sh script in its root directory. This script is executed with the PORT environment variable set to the allocated port.
#!/bin/bash
# server.sh - starts your web server on $PORT
npm run dev -- --port $PORT
The server.sh approach is technology-agnostic - it works with any language or framework that can start an HTTP server on a specified port.
Examples
Node.js (Vite/React/Next.js):
#!/bin/bash
npm install
npm run dev -- --port $PORT
Python (Flask):
#!/bin/bash
pip install -r requirements.txt
python app.py
# app.py
from flask import Flask
import os
app = Flask(__name__)
@app.route('/')
def hello():
return 'Hello from Flask!'
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
app.run(host='0.0.0.0', port=port)
Python (FastAPI with uv):
#!/bin/bash
uv run fastapi run app.py --port $PORT
Go:
#!/bin/bash
go run main.go
Ruby (Rails):
#!/bin/bash
bundle install
bin/rails server -p $PORT
Admin API
The admin API enables runtime management of repositories without restarting the server. This is useful for adding new repos, removing existing ones, or temporarily pausing traffic to specific repos.
Enabling the Admin API
Start the server with the --admin-secret option:
# Enable admin API
preview-server --admin-secret "your-secret-here"
# Start with no repos (configure at runtime)
preview-server --admin-secret "your-secret-here"
# Persist changes to a JSON file
preview-server --admin-secret "your-secret-here" --persist-repos /var/lib/preview-server/repos.json
API Endpoints
All endpoints require the x-api-secret header with the admin secret.
| Endpoint | Method | Body | Description |
|---|---|---|---|
/-/preview-server/auth-check |
POST | - | Verify the admin secret is valid |
/-/preview-server/repos/add |
POST | {"label": "...", "path": "..."} |
Add a new repo |
/-/preview-server/repos/remove |
POST | {"label": "..."} |
Remove a repo |
/-/preview-server/repos/pause |
POST | {"label": "..."} |
Pause traffic to a repo (returns 503) |
/-/preview-server/repos/resume |
POST | {"label": "..."} |
Resume traffic to a paused repo |
Examples
# Check authentication
curl -X POST http://localhost:8000/-/preview-server/auth-check \
-H "x-api-secret: your-secret-here"
# Returns: {"ok": true}
# Add a new repository
curl -X POST http://localhost:8000/-/preview-server/repos/add \
-H "x-api-secret: your-secret-here" \
-H "Content-Type: application/json" \
-d '{"label": "frontend", "path": "/path/to/frontend"}'
# Returns: {"ok": true, "label": "frontend"}
# Pause a repository (traffic returns 503)
curl -X POST http://localhost:8000/-/preview-server/repos/pause \
-H "x-api-secret: your-secret-here" \
-H "Content-Type: application/json" \
-d '{"label": "frontend"}'
# Returns: {"ok": true}
# Resume a paused repository
curl -X POST http://localhost:8000/-/preview-server/repos/resume \
-H "x-api-secret: your-secret-here" \
-H "Content-Type: application/json" \
-d '{"label": "frontend"}'
# Returns: {"ok": true}
# Remove a repository
curl -X POST http://localhost:8000/-/preview-server/repos/remove \
-H "x-api-secret: your-secret-here" \
-H "Content-Type: application/json" \
-d '{"label": "frontend"}'
# Returns: {"ok": true}
Web UI
When the admin API is enabled, the status dashboard at /-/preview-server includes a "Configuration" section where you can:
- Authenticate with the admin secret
- View all configured repositories with their pause status
- Pause/resume individual repositories
- Add new repositories
- Remove repositories
The UI stores the admin secret in localStorage (persists across browser sessions) and remembers the configuration section's open/closed state in localStorage.
Persistence
By default, repository changes are stored in memory and lost when the server restarts. Use --persist-repos to save changes to a JSON file:
preview-server --admin-secret "secret" --persist-repos /var/lib/preview-server/repos.json
The persistence file format:
{
"repos": [
{"label": "frontend", "path": "/path/to/frontend", "paused": false},
{"label": "backend", "path": "https://github.com/org/backend", "paused": true}
]
}
On startup, if the persistence file exists, repos are loaded from it (ignoring any repos specified on the command line). Changes made via the API are automatically saved.
Development
Run tests:
uv run pytest -v
Architecture
The server consists of several key components:
- Main ASGI Server: Routes requests based on hostname subdomain
- Sub-Server Manager: Manages process lifecycle and resource cleanup
- Git Manager: Handles repository cloning, pulling, and ref resolution
- Status Tracker: Tracks request metrics and server health
Testing
Tests use pytest with async support. Each feature implements Test-Driven Development:
- Write test case (red)
- Implement feature (green)
- Commit with passing tests and README update
Quick Start
# Start the preview server
preview-server ~/path/to/your/repo -p 3000
# In another terminal, test it
curl http://localhost:3000/-/preview-server
# Access a preview deployment for a specific branch
# (requires setting up .localhost DNS resolution on your system)
curl http://main.localhost:3000/
What Works Now ✅
You can now:
- Start the preview server with
preview-server ~/dev/my-project -p 3001 - Make requests to
http://main.localhost:3001(or any branch) - The server automatically:
- Creates a git worktree for the requested branch
- Starts a sub-server for that branch
- Proxies HTTP requests to the sub-server
- Tracks running servers and metrics
- Returns the response to the client
Example Output
$ curl http://main.localhost:3001/
<html>
<head><title>Example App</title></head>
<body>
<h1>Example App</h1>
<p>Branch: <code>unknown</code></p>
<p>This is a test app for preview deployment.</p>
</body>
</html>
$ curl http://localhost:3001/-/preview-server.json
{
"status": "ok",
"running_servers": 1,
"idle_ttl_seconds": 300.0,
"sub_servers": [
{
"ref": "main",
"port": 53153,
"pid": 24586,
"uptime_seconds": 120,
"restart_attempts": 0,
"command": "./server.sh",
"recent_logs": ["[2024-12-20 10:30:45] [STARTUP] Started..."]
}
]
}
$ curl http://localhost:3001/-/preview-server
(returns HTML dashboard with Stream logs toggle for live updates)
Implementation Status
- Phase 1: CLI argument parsing and git setup (COMPLETE)
- CLI argument parsing
- Duration parsing
- Port selection
- Git repository initialization and cloning
- Phase 2: ASGI app and request routing (COMPLETE)
- ASGI application with Starlette
- Request routing by hostname subdomain
- HTTP request proxying to sub-servers
- CLI entry point with uvicorn
- Sub-server manager with process lifecycle
- Remote git branch handling
- Fallback process startup methods
- Phase 3: Status Endpoints (COMPLETE)
- JSON API at /-/preview-server.json
- HTML dashboard at /-/preview-server
- Auto-refreshing status display
- Comprehensive test coverage
- Phase 4: Dynamic git pulls on unknown refs (COMPLETE)
- Phase 5: Idle timeout and scale-to-zero (COMPLETE)
- Phase 6: Basic authentication (COMPLETE)
- HTTP Basic auth protecting all endpoints
- Constant-time credential comparison (timing attack protection)
- Proper 401 responses with WWW-Authenticate header
- Comprehensive test coverage (30 tests)
- Phase 7: Proxy headers (COMPLETE)
- X-Forwarded-For header chain
- X-Forwarded-Host header
- X-Forwarded-Proto header
- X-Real-IP header
- Phase 8: Streaming and WebSocket support (COMPLETE)
- Stream request body (no buffering for large uploads)
- Stream response body (already implemented)
- WebSocket proxy with bidirectional message relay
- WebSocket authentication via query token parameter
- Comprehensive test coverage (22 tests)
- Phase 9: Auto-pull for branches (COMPLETE)
- CLI --auto-pull argument with duration format
- TOML config file support for auto-pull
- Branch detection (distinguishes branches from tags/commits)
- Automatic git fetch + reset on stale branches
- 30-second timeout for pull operations
- Multi-repo mode support
- Phase 10: Hostname Signing (COMPLETE)
- CLI --secret and --sign arguments
- TOML config file support for secret
- HMAC-SHA256 signature generation with lowercase hex encoding (subdomain-safe)
- Constant-time signature verification (timing attack protection)
- 403 rejection for invalid/missing signatures
- Comprehensive test coverage
- Phase 11: Admin API (COMPLETE)
- CLI --admin-secret and --persist-repos arguments
- TOML config file support for admin-secret and persist-repos
- Runtime repo add/remove/pause/resume via REST API
- JSON file persistence for repo configuration
- Web UI for repo management in status dashboard
- Constant-time admin secret verification (timing attack protection)
- Comprehensive test coverage (34 tests)
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file preview_server-0.2a0.tar.gz.
File metadata
- Download URL: preview_server-0.2a0.tar.gz
- Upload date:
- Size: 41.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
49286a47be93fdbe4a9b3e272382ef2afc243cec20717302e6bc105adf5669d2
|
|
| MD5 |
d0a26736d2581c7d4245c1fab658e07e
|
|
| BLAKE2b-256 |
e40fe3f0f18ed0a63fa5521c61436850e9faf1a43fa3fc56c565395c573009a0
|
Provenance
The following attestation bundles were made for preview_server-0.2a0.tar.gz:
Publisher:
publish.yml on simonw/preview-server
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
preview_server-0.2a0.tar.gz -
Subject digest:
49286a47be93fdbe4a9b3e272382ef2afc243cec20717302e6bc105adf5669d2 - Sigstore transparency entry: 774436347
- Sigstore integration time:
-
Permalink:
simonw/preview-server@011fcdfab3369d0d77b95f12f56ff6a0d14e574f -
Branch / Tag:
refs/tags/0.2a0 - Owner: https://github.com/simonw
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@011fcdfab3369d0d77b95f12f56ff6a0d14e574f -
Trigger Event:
release
-
Statement type:
File details
Details for the file preview_server-0.2a0-py3-none-any.whl.
File metadata
- Download URL: preview_server-0.2a0-py3-none-any.whl
- Upload date:
- Size: 46.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5acebd53b15aaf5020d111aa34cb52ccaadfbc2fd54ec87d81e94a3bade53666
|
|
| MD5 |
7fd3afcd7307ac86b43a58c6e0a8a97b
|
|
| BLAKE2b-256 |
23233dea194328dd843c23b290e63c58eea52de43394565b66db30c0e2229456
|
Provenance
The following attestation bundles were made for preview_server-0.2a0-py3-none-any.whl:
Publisher:
publish.yml on simonw/preview-server
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
preview_server-0.2a0-py3-none-any.whl -
Subject digest:
5acebd53b15aaf5020d111aa34cb52ccaadfbc2fd54ec87d81e94a3bade53666 - Sigstore transparency entry: 774436349
- Sigstore integration time:
-
Permalink:
simonw/preview-server@011fcdfab3369d0d77b95f12f56ff6a0d14e574f -
Branch / Tag:
refs/tags/0.2a0 - Owner: https://github.com/simonw
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@011fcdfab3369d0d77b95f12f56ff6a0d14e574f -
Trigger Event:
release
-
Statement type: