The fastest way to ship containers to your own server.
One command. Automatic HTTPS. Zero config.
zero deploy ghcr.io/shipzero/demo:latest
# 🚀 Your app is live: https://demo.example.comPlatforms like Vercel and Railway have great DX — but they're expensive, lock you in, and don't support arbitrary Docker images. Self-hosting is flexible and cheap, but getting a container live with HTTPS means wiring up nginx, Certbot, deploy scripts, and hoping nothing breaks.
zero closes that gap. You point it at a Docker image, and it handles everything else — port detection, domain routing, TLS, health checks, zero-downtime swaps. Zero config files. Zero moving parts. Just one command and your app is live.
Any Linux VPS with root access:
curl -fsSL https://shipzero.sh/install.sh | sudo bashThe installer sets up Docker, prompts for your domain and email (for TLS), and starts zero.
On your local machine:
curl -fsSL https://shipzero.sh/cli/install.sh | bashzero login root@example.comAuthentication uses SSH — if you can SSH into the server, you can use zero.
zero deploy ghcr.io/shipzero/demo:latestThat's it. zero figures out the port, assigns a domain, provisions a TLS certificate, and routes traffic.
✓ Pulling image
✓ Starting container
✓ Detected port: 3000
✓ Health check passed
✓ Your app is live: https://demo.example.com
- HTTPS by default — certificates are provisioned automatically
- Zero-downtime — traffic switches only after the new version is healthy
- Preview deployments —
zero deploy myapp --preview pr-21 - One-command rollback —
zero rollback myapp - Webhooks — push to your registry, zero deploys it
- No reverse proxy config — routing is built in
- Live metrics — CPU, memory, and network in the terminal
Two dependencies. One container. No database. No web UI. No YAML.
Only the image is required. Everything else is inferred:
| What | How it works |
|---|---|
| Name | Last segment of the image path (ghcr.io/shipzero/demo → demo) |
| Port | Read from the image's EXPOSE directive, falls back to 3000 |
| Domain | <name>.<server-domain> unless --host-port is set |
| Health | TCP connection check, or HTTP GET when --health-path is set |
# Deploy with all defaults
zero deploy ghcr.io/shipzero/demo:latest
# Override any default
zero deploy ghcr.io/shipzero/demo:latest --name api --domain api.example.com --port 8080
# Redeploy an existing app
zero deploy myapp
# Deploy a specific tag
zero deploy myapp --tag v1.2.3
# Expose on a host port instead of a domain
zero deploy ghcr.io/shipzero/demo:latest --host-port 8888All options:
| Flag | Description | Default |
|---|---|---|
--name |
App name (overrides inferred name) | (from image) |
--domain |
Domain for routing and TLS | <name>.<server-domain> |
--port |
Internal container port | (auto-detect) |
--host-port |
Expose directly on a host port (skips auto-domain) | — |
--tag |
Image tag to deploy | latest |
--command |
Container startup command | — |
--volume |
Volumes, comma-separated (e.g. pgdata:/var/lib/postgresql/data) |
— |
--health-path |
HTTP health check endpoint | — |
--health-timeout |
Health check timeout (e.g. 30s, 3m) |
60s |
--env |
Env vars, comma-separated (e.g. KEY=val,KEY2=val2) |
— |
--preview |
Deploy as a preview environment | — |
--ttl |
Time to live for previews (e.g. 24h, 7d) |
7d |
Pass env vars inline with --env:
zero deploy ghcr.io/shipzero/demo:latest --env DATABASE_URL=postgres://localhost/mydb,SECRET_KEY=abc123Or manage them separately — changes take effect on the next deploy:
zero env set myapp DATABASE_URL=postgres://localhost/mydb SECRET_KEY=abc123
zero env list myapp
zero env remove myapp SECRET_KEYzero deploy postgres:16 --name postgres --port 5432 --volume pgdata:/var/lib/postgresql/dataFormat: source:destination[:mode]
zero registry login ghcr.io --user <username> --password <token>
zero registry list
zero registry logout ghcr.ioFor multi-container apps:
zero deploy --compose docker-compose.yml --service web --name mystack --domain mystack.example.com --port 3000| Flag | Description |
|---|---|
--compose |
Path to a docker-compose.yml file (required) |
--service |
The entry service that receives traffic (required) |
--name |
App name (required) |
--image-prefix |
Shared image prefix for tag substitution (e.g. ghcr.io/org/project) |
The Compose file is uploaded to the server. On deploy, zero pulls images, starts services, and health-checks the entry service before routing traffic.
--image-prefix explained: When you pass --image-prefix ghcr.io/you/mystack, zero replaces the tag of every
image in your Compose file that starts with that prefix. This is what makes --tag, webhooks, and preview
deployments work for Compose apps.
zero deploy --compose docker-compose.yml --service web --name mystack --image-prefix ghcr.io/you/mystack
# Now these work:
zero deploy mystack --tag v2 # updates all matching images to :v2
zero deploy mystack --preview pr-21 # preview with tag :pr-21zero logs myapp # stream app logs
zero logs myapp --tail 500 # last 500 lines (default: 100)
zero logs --server # stream server logs
zero metrics myapp # live CPU, memory, networkmyapp
cpu ██████░░░░░░░░░░░░░░ 28.3%
memory ████████████░░░░░░░░ 312 MB / 512 MB (60.9%)
net ↓ 1.2 MB/s
net ↑ 340 KB/s
zero rollback myappStarts a new container from the previous image and swaps traffic once healthy.
zero stop myapp # stop container, traffic returns 502
zero start myapp # restart and health-check before routing
zero remove myapp # remove app and all its containersApps can have multiple domains. The first domain is the primary (used for preview subdomains).
zero domain add myapp staging.myapp.com # add a domain (no redeploy needed)
zero domain list myapp # list all domains
zero domain remove myapp staging.myapp.com # remove a domainThe --domain flag on zero deploy sets the initial domain when creating an app. Use zero domain add for additional domains.
zero history myapp
zero list # list all apps with status, URL, imageSpin up a temporary version of any app:
zero deploy myapp --preview pr-21
# => https://preview-pr-21.myapp.example.comPreviews expire automatically (default: 7 days). One flag, temporary URL, automatic cleanup.
zero deploy myapp --preview feat-1 --tag feat-branch --ttl 24h
zero logs myapp --preview pr-21
zero metrics myapp --preview pr-21
zero remove myapp --preview pr-21Every app gets a unique webhook URL. Push an image to your registry, zero deploys it automatically.
zero webhook url myappAdd the URL as a webhook in GitHub Container Registry or Docker Hub. Payloads are verified with HMAC-SHA256.
Non-matching tags automatically create preview deployments when the app has a domain.
- Pull — image pulled from the registry
- Start — new container started on an ephemeral port bound to localhost
- Health check — TCP or HTTP check, up to 60 seconds
- Swap — reverse proxy route updated atomically, old container removed
If the health check fails, the new container is discarded. Traffic stays on the previous version.
No nginx. No Traefik. zero includes a built-in reverse proxy:
- Routes requests to containers based on the
Hostheader - TLS termination with automatic certificate selection (SNI)
- Security headers:
Strict-Transport-Security,X-Content-Type-Options,X-Frame-Options - Forwarding headers:
X-Forwarded-For,X-Real-IP,X-Forwarded-Proto - Request timeout: 60s, max body size: 100 MB (configurable via
MAX_BODY_SIZE)
zero is a single-server deployment engine. One server, any number of apps. If you need multi-node orchestration, team RBAC, or a web dashboard, zero is not the right tool.
- Linux server (Ubuntu 22.04+ recommended)
- Root access
- A domain pointing to your server (for HTTPS and automatic subdomains)
Configuration is stored in /opt/zero/.env:
| Variable | Description | Default |
|---|---|---|
TOKEN |
Internal auth token (do not share) | (generated) |
JWT_SECRET |
Secret for signing JWT tokens | (generated) |
DOMAIN |
Server domain (used for app subdomains and TLS) | (server IP) |
EMAIL |
Let's Encrypt email (enables automatic TLS) | — |
API_PORT |
API server port | 2020 |
CERT_RENEW_BEFORE_DAYS |
Renew certificates this many days before expiry | 30 |
PREVIEW_TTL |
Default time to live for preview deployments | 7d |
MAX_BODY_SIZE |
Maximum request body size for the reverse proxy | 100m |
zero upgrade --server # upgrade remotely via CLI
zero upgrade --canary # install canary (pre-release) versionOr re-run the install script on the server.
zero provisions and renews TLS certificates via Let's Encrypt automatically when EMAIL is set and DOMAIN is a
real domain (not an IP). Certificates are provisioned on first deploy and renewed within 30 days of expiry. HTTP
requests are redirected to HTTPS.
Server:
docker compose -f /opt/zero/docker-compose.yml down
rm -rf /opt/zero /var/lib/zeroCLI:
rm -rf ~/.zerozero <command> [options]
deploy <image-or-app> [options] Deploy an app (creates if new)
domain <add|remove|list> <app> [domain] Manage app domains
env <set|list|remove> <app> [args] Manage environment variables
history <app> Show deployment history
list List all apps
login <user@server> Authenticate via SSH
logs <app|--server> [--tail <n>] [--preview <label>]
Stream app or server logs
metrics <app|--server> [--preview <label>] Show live resource usage
registry <login|logout|list> [server] Manage registry credentials
remove <app> [--preview <label>] [--force] Remove an app or preview
rollback <app> [--force] Roll back to previous deployment
start <app> Start a stopped app
status Show server connection info
stop <app> [--force] Stop a running app
upgrade [--server] [--all] Upgrade CLI and/or server
version Show CLI and server version
webhook url <app> Show and rotate webhook URL

