Documentation

Everything you need to deploy and operate containers with zero. From initial setup to advanced features.

Quickstart

Get your first app live in under five minutes. All you need is a Linux VPS with root access and a domain pointing to it.

1. Set up the server

On your VPS — run the install script on any Linux server (Ubuntu 22.04+ recommended):

$ curl -fsSL https://shipzero.sh/install.sh | sudo bash

The installer sets up Docker, prompts for your domain and email (for TLS), and starts zero. After installation, point your DNS to the server:

TypeNameValue
Aexample.comYour server IPRequired
A*.example.comYour server IPRecommended for auto-subdomains

2. Install the CLI

On your machine — install the zero CLI locally:

$ curl -fsSL https://shipzero.sh/cli/install.sh | bash

3. Connect

On your machine — authentication uses SSH. If you can SSH into the server, you can use zero.

$ zero login root@example.com

Login is per directory. zero stores a .zero/config.json file in your current working directory and automatically adds .zero/ to your .gitignore. This means you can work with multiple servers from different project directories.

4. Deploy

$ zero deploy ghcr.io/shipzero/demo:latest
✓ Pulling image
✓ Starting container
✓ Detected port: 3000
✓ Health check passed
✓ Your app is live: https://demo.example.com

That's it. zero figures out the port, assigns a domain, provisions a TLS certificate, and routes traffic.


Deploying apps

Only the image is required. Everything else is inferred:

WhatHow it works
NameLast segment of the image path (ghcr.io/shipzero/demo becomes demo)
PortRead from the image's EXPOSE directive, falls back to 3000
Domain<name>.<server-domain> unless --host-port is set
HealthTCP 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 8888

Deploy options

FlagDescriptionDefault
--nameApp name (overrides inferred name)From image
--domainDomain for routing and TLS<name>.<server-domain>
--portInternal container portAuto-detect
--host-portExpose directly on a host port (skips auto-domain)-
--tagImage tag to deploylatest
--commandContainer startup command-
--volumeVolumes, comma-separated-
--health-pathHTTP health check endpoint-
--health-timeoutHealth check timeout (e.g. 30s, 3m)60s
--envEnv vars, comma-separated (e.g. KEY=val,KEY2=val2)-
--previewDeploy as a preview environment-
--ttlTime to live for previews (e.g. 24h, 7d)7d

Health checks

By default, zero checks whether the container accepts TCP connections on the detected port. For HTTP health checks, pass --health-path:

$ zero deploy ghcr.io/shipzero/demo:latest --health-path /health

The health check runs for up to 60 seconds (configurable with --health-timeout). HTTP checks accept any response with status < 500. If the container crashes during the health check (restart loop), the deployment fails immediately.

If the health check fails, the new container is discarded and traffic stays on the previous version. For new apps, the app is removed entirely.

Host port mode

Not every app needs a domain. Use --host-port to expose a container directly on a port:

$ zero deploy postgres:16 --name db --port 5432 --host-port 5432

Host port mode skips domain assignment and TLS. The container is accessible at http://<server-ip>:<port>. Useful for databases, message queues, and other non-HTTP services.


Environment variables

Pass env vars inline with --env:

$ zero deploy ghcr.io/shipzero/demo:latest --env DATABASE_URL=postgres://localhost/mydb,SECRET_KEY=abc123

Or 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_KEY

Volumes

Mount persistent volumes with the --volume flag. Format: source:destination[:mode]

$ zero deploy postgres:16 --name postgres --port 5432 --volume pgdata:/var/lib/postgresql/data

Private registries

Authenticate with GitHub Container Registry, Docker Hub, or any OCI-compatible registry:

$ zero registry login ghcr.io --user <username> --password <token>
$ zero registry list
$ zero registry logout ghcr.io

Docker Compose

For multi-container apps, pass a Compose file:

$ zero deploy --compose docker-compose.yml --service web --name mystack --domain mystack.example.com --port 3000
FlagDescription
--composePath to a docker-compose.yml file (required)
--serviceThe entry service that receives traffic (required)
--nameApp name (required)
--image-prefixShared 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

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 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
$ zero deploy mystack --preview pr-21

Logs & metrics

$ zero 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, network
myapp

  cpu     ██████░░░░░░░░░░░░░░  28.3%
  memory  ████████████░░░░░░░░  312 MB / 512 MB (60.9%)
  net ↓   1.2 MB/s
  net ↑   340 KB/s

Rollback

Roll back to the previous deployment. A new container is started from the previous image and traffic swaps once healthy.

$ zero rollback myapp

Start, stop, remove

$ 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 containers

Domains

Apps can have multiple domains. The first domain is the primary (used for preview subdomains).

$ zero domain add myapp staging.myapp.com
$ zero domain list myapp
$ zero domain remove myapp staging.myapp.com

The --domain flag on zero deploy sets the initial domain when creating an app. Use zero domain add for additional domains.


Deployment history

$ zero history myapp
$ zero list # list all apps with status, URL, image

Preview deployments

Spin up a temporary version of any app. Previews get a unique subdomain (preview-<label>.<primary-domain>) and expire automatically (default: 7 days). The parent app must have at least one domain.

$ zero deploy myapp --preview pr-21
✓ Preview live: https://preview-pr-21.myapp.example.com
# Custom tag and TTL
$ zero deploy myapp --preview feat-1 --tag feat-branch --ttl 24h

# Logs and metrics for previews
$ zero logs myapp --preview pr-21
$ zero metrics myapp --preview pr-21

# Remove a preview manually
$ zero remove myapp --preview pr-21

Expired previews are cleaned up automatically every hour. Duration format supports 24h, 7d, 14d, etc.


Webhooks

Every app gets a webhook URL and a signing secret. Push an image to your registry, zero deploys it automatically.

Getting your webhook credentials

When you deploy an app for the first time, zero shows the webhook URL and secret:

$ zero deploy ghcr.io/you/myapp:latest
✓ ...
i Webhook URL: https://example.com/webhooks/myapp
i Webhook secret: a1b2c3d4e5f6...

Important: Running zero webhook url rotates the secret and displays the new one. The old secret stops working immediately. Only run this when you intend to rotate.

$ zero webhook url myapp
# ⚠ Rotates the secret — update it in your registry afterwards

Auto-deploy from GitHub Actions

The most common setup: your CI builds and pushes a Docker image, then notifies zero to deploy it. Add a step to your GitHub Actions workflow:

  1. Store the webhook secret as a GitHub repository secret (e.g. ZERO_WEBHOOK_SECRET)
  2. Add a deploy step after your Docker push:
# Add this step after docker/build-push-action
- name: Deploy via zero
  run: |
    PAYLOAD='{"push_data":{"tag":"latest"}}'
    SIGNATURE="sha256=$(echo -n "$PAYLOAD" | \
      openssl dgst -sha256 -hmac "${{ secrets.ZERO_WEBHOOK_SECRET }}" | \
      awk '{print $2}')"
    curl -s -X POST \
      -H "Content-Type: application/json" \
      -H "x-hub-signature-256: $SIGNATURE" \
      -d "$PAYLOAD" \
      https://example.com/webhooks/myapp

Replace myapp with your app name and "latest" with the tag you pushed. For PR-based preview deploys, use "pr-${{ github.event.pull_request.number }}" as the tag.

Triggering from a script

The same request works from any environment. The body must be JSON with a tag, and the x-hub-signature-256 header must contain the HMAC-SHA256 signature of the body:

PAYLOAD='{"push_data":{"tag":"v1.2.3"}}'
SECRET="your-webhook-secret"
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
 
curl -X POST \
  -H "Content-Type: application/json" \
  -H "x-hub-signature-256: $SIGNATURE" \
  -d "$PAYLOAD" \
  https://example.com/webhooks/myapp

zero accepts two payload formats for the tag:

Tag matching and previews

When a webhook arrives, zero checks the tag against the app's tracked tag (set during deploy):

If trackTag is set to any, every tag triggers a main deploy.

Secret rotation

Running zero webhook url rotates the secret. The URL stays the same, but the old secret stops working immediately. Update the secret in your registry after rotating.


Reverse proxy

No nginx. No Traefik. zero includes a built-in reverse proxy:


TLS

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 (checked every 12 hours). HTTP requests are redirected to HTTPS.

Certificates are obtained via ACME HTTP-01 challenges. The built-in proxy handles this automatically on port 80.

Running without a domain

zero works without a domain by setting DOMAIN to your server's IP address. In this mode:

This mode is functional but not recommended for production. Use a domain for HTTPS, automatic subdomains, and preview environments.


CLI reference

zero <command> [options]
deploy <image-or-app> [options]
Deploy an app (creates if new). See deploy options for all flags.
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 with status, URL, and image.
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] [--canary]
Upgrade CLI and/or server.
version
Show CLI and server version.
webhook url <app>
Show and rotate webhook URL.

Aliases

Common commands have short aliases: zero ls (list), zero rm (remove), zero domain ls (domain list), zero env ls (env list), zero registry ls (registry list).


Server configuration

Configuration is stored in /opt/zero/.env:

VariableDescriptionDefault
TOKENInternal auth token (do not share)Generated
JWT_SECRETSecret for signing JWT tokensGenerated
DOMAINServer domain (used for app subdomains and TLS)Server IP
EMAILLet's Encrypt email (enables automatic TLS)-
API_PORTAPI server port2020
CERT_RENEW_BEFORE_DAYSRenew certificates this many days before expiry30
PREVIEW_TTLDefault time to live for preview deployments7d
MAX_BODY_SIZEMaximum request body size for the reverse proxy100m

Server requirements


Upgrade

CLI and server can be upgraded independently:

# Upgrade the CLI to the latest version
$ zero upgrade

# Upgrade the server remotely (via SSH)
$ zero upgrade --server

# Upgrade both at once
$ zero upgrade --all

# Install a pre-release (canary) version
$ zero upgrade --canary

# Reinstall the current version (useful for corrupted installs)
$ zero upgrade --force

You can also re-run the install script directly on the server to upgrade the server component.


Uninstall

Server

$ docker compose -f /opt/zero/docker-compose.yml down
$ rm -rf /opt/zero /var/lib/zero

CLI

$ rm -rf ~/.zero