# zero — Ship containers to your own server

> One-command Docker container deployment for your own Linux server. Automatic HTTPS, zero-downtime, preview environments, webhook auto-deploy. Open source, MIT licensed.

- Site: https://shipzero.sh
- Docs: https://shipzero.sh/docs.html
- Source: https://github.com/shipzero/zero
- License: MIT

---

## What zero is

zero is a single-server deployment engine. You give it a Docker image; it pulls the image, starts the container on an ephemeral port, runs a health check, and atomically swaps traffic. If the health check fails, traffic stays on the previous version. No config files. No YAML. No platform fees.

Authentication is SSH — if you can SSH into the server, you can use zero. Login is per directory: zero stores `.zero/config.json` in your current working directory and adds `.zero/` to `.gitignore`, so you can manage multiple servers from different project directories.

### What it gives you

- Deploy any OCI/Docker image with one command
- Automatic HTTPS via Let's Encrypt (HTTP-01 ACME challenges, auto-renew 30 days before expiry)
- Zero-downtime deploys (start → health check → atomic traffic swap)
- Preview environments on auto-generated subdomains (`preview-<label>.<primary-domain>`)
- Webhook auto-deploy from any container registry (HMAC-SHA256 signed)
- Docker Compose support (multi-container apps in one command)
- Instant rollback to previous deployment
- Live logs and metrics streamed to your terminal
- Built-in reverse proxy with WebSocket support and SNI-based TLS
- Last 10 deployments retained per app for rollback; older images pruned automatically

### Scope

One server, any number of apps. Not for: multi-node orchestration, team RBAC, web dashboards. By design.

---

## Quickstart

A Linux VPS with root access and a domain pointing to it. That is the entire requirements list.

### 1. Set up the server

On your VPS, run the install script (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. Then point DNS:

| Type | Name           | Value           | Notes                                |
|------|----------------|-----------------|--------------------------------------|
| A    | example.com    | Your server IP  | Required                             |
| A    | *.example.com  | Your server IP  | Recommended for auto-subdomains      |

### 2. Install the CLI

On your machine:

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

### 3. Connect

```
zero login root@example.com
```

### 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
```

---

## Deploying apps

Only the image is required. Everything else is inferred:

| What   | How it works                                                                                   |
|--------|------------------------------------------------------------------------------------------------|
| Name   | Last segment of the image path (`ghcr.io/shipzero/demo` becomes `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 8888
```

### Deploy 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                                     | —                      |
| `--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`                   |

### 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 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. On failure, 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
```

| 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. After each successful deploy, dangling images are pruned automatically so repeatedly pulled rolling tags don't fill up the disk.

### 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
```

Example metrics output:

```
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 keeps the last 10 deployments per app for rollback. Older deployments — and their container images — are removed automatically to keep disk usage in check.

```
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.

1. Store the webhook secret as a GitHub repository secret (e.g. `ZERO_WEBHOOK_SECRET`)
2. Add a deploy step after your Docker push:

```yaml
# 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:

- **Docker Hub:** `{"push_data": {"tag": "v1.2.3"}}`
- **GHCR:** `{"package": {"package_version": {"container_metadata": {"tag": {"name": "v1.2.3"}}}}}`

### Tag matching and previews

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

- **Tag matches** → triggers a deploy of the main app
- **Tag doesn't match** and app has a domain → creates a preview deployment automatically
- **Tag doesn't match** and app has no domain → ignored

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:

- Routes requests based on the `Host` header
- TLS termination with automatic certificate selection (SNI)
- WebSocket support with automatic upgrade detection (configurable idle timeout, default 30 minutes)
- 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: 5m, headers timeout: 30s (configurable via `PROXY_REQUEST_TIMEOUT`, `PROXY_HEADERS_TIMEOUT`)
- Max body size: 100 MB (configurable via `MAX_BODY_SIZE`)
- Connection limits: 1024 global, 128 per IP

---

## 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:

- TLS is disabled — all traffic is HTTP only
- Apps are accessible via `--host-port` instead of subdomains
- Webhooks use `http://<ip>:<api-port>/webhooks/<appName>`
- Preview deployments are not available (they require a domain for subdomains)

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

---

## CLI reference

```
zero <command> [options]
```

| Command                                                                     | Description                                              |
|-----------------------------------------------------------------------------|----------------------------------------------------------|
| `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 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`:

| Variable                | Description                                                  | Default     |
|-------------------------|--------------------------------------------------------------|-------------|
| `TOKEN`                 | Internal auth token (do not share)                           | Generated   |
| `JWT_SECRET`            | Secret for signing JWT tokens (do not share)                 | 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`      |
| `PROXY_REQUEST_TIMEOUT` | Reverse proxy request timeout (e.g. `60s`, `5m`)             | `5m`        |
| `PROXY_HEADERS_TIMEOUT` | Reverse proxy headers timeout                                | `30s`       |
| `PROXY_WS_IDLE_TIMEOUT` | WebSocket idle timeout                                       | `30m`       |

### Server requirements

- Linux server (Ubuntu 22.04+ recommended)
- Root access
- A domain pointing to your server (recommended for HTTPS and auto-subdomains; IP-only mode is also supported)

---

## 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
```

---

## License

MIT. See https://github.com/shipzero/zero/blob/main/LICENSE
