diff options
| author | bndw <ben@bdw.to> | 2026-02-10 21:29:32 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-10 21:29:32 -0800 |
| commit | f5b667c80e49117c94481d49c5b0c77dbcf2804a (patch) | |
| tree | b9b6d8f4cca99639b47fecacebefcd769ba48ed7 /README.md | |
| parent | c49a067ac84ac5c1691ecf4db6a9bf791246899f (diff) | |
Rewrite README and add SECURITY.md
Document both deployment modes (git push and direct), all commands,
architecture, VPS file layout, and vanity imports. Add SECURITY.md
covering threat model, mitigations, and known gaps.
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 350 |
1 files changed, 207 insertions, 143 deletions
| @@ -1,219 +1,283 @@ | |||
| 1 | # Ship - VPS Deployment CLI | 1 | # Ship |
| 2 | 2 | ||
| 3 | Simple CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy. | 3 | Ship deploys apps and static sites to a VPS over SSH. It handles HTTPS certificates, port allocation, systemd services, and reverse proxying — all with zero dependencies beyond SSH access. |
| 4 | 4 | ||
| 5 | ## Features | 5 | There are two deployment modes: |
| 6 | 6 | ||
| 7 | - Single command deployment from your laptop | 7 | - **Git push** — push to a bare repo on the VPS, which triggers a Docker build and deploy via post-receive hooks. Deployment config (systemd unit, Caddyfile) lives in `.ship/` in your repo and is versioned alongside code. |
| 8 | - Automatic HTTPS via Caddy + Let's Encrypt | 8 | - **Direct** — SCP a pre-built binary or rsync a static directory to the VPS. Ship generates and installs the systemd unit and Caddy config on your behalf. |
| 9 | - Automatic port allocation (no manual tracking) | ||
| 10 | - Environment variable management | ||
| 11 | - Systemd process management with auto-restart | ||
| 12 | - Support for multiple apps/sites on one VPS | ||
| 13 | - State stored locally (VPS is stateless and easily recreatable) | ||
| 14 | - Zero dependencies on VPS (just installs Caddy) | ||
| 15 | 9 | ||
| 16 | ## Installation | 10 | If a base domain is configured, Ship also serves **Go vanity imports** and **git HTTPS cloning** from the same domain, so `go get yourdomain.com/foo` works with zero extra setup. |
| 17 | 11 | ||
| 18 | ```bash | 12 | ## Install |
| 19 | # Build the CLI | ||
| 20 | go build -o ~/bin/ship ./cmd/ship | ||
| 21 | 13 | ||
| 22 | # Or install to GOPATH | 14 | ``` |
| 23 | go install ./cmd/ship | 15 | go install github.com/bdw/ship/cmd/ship@latest |
| 24 | ``` | 16 | ``` |
| 25 | 17 | ||
| 26 | ## Quick Start | 18 | Or build from source: |
| 27 | |||
| 28 | ### 1. Initialize Your VPS (One-time) | ||
| 29 | 19 | ||
| 30 | ```bash | 20 | ``` |
| 31 | # Initialize a fresh VPS (this sets it as the default host) | 21 | go build -o ship ./cmd/ship |
| 32 | ship host init user@your-vps-ip | ||
| 33 | ``` | 22 | ``` |
| 34 | 23 | ||
| 35 | This will: | 24 | ## Quick start |
| 36 | - Install Caddy | ||
| 37 | - Configure Caddy for automatic HTTPS | ||
| 38 | - Create necessary directories | ||
| 39 | - Set up the VPS for deployments | ||
| 40 | 25 | ||
| 41 | ### 2. Deploy a Go App | 26 | ### 1. Set up the VPS |
| 42 | 27 | ||
| 43 | ```bash | 28 | ``` |
| 44 | # Build your app for Linux | 29 | ship host init --host user@your-vps --base-domain example.com |
| 45 | GOOS=linux GOARCH=amd64 go build -o myapp | 30 | ``` |
| 46 | 31 | ||
| 47 | # Deploy it | 32 | This installs Caddy, Docker, git, and fcgiwrap. It creates a `git` user for push access, configures sudoers for deploy hooks, sets up vanity import serving, and enables automatic HTTPS. The host becomes the default for subsequent commands. |
| 48 | ship --binary ./myapp --domain api.example.com | ||
| 49 | 33 | ||
| 50 | # With environment variables | 34 | If you don't need git-push deploys or vanity imports, omit `--base-domain`: |
| 51 | ship --binary ./myapp --domain api.example.com \ | ||
| 52 | --env DB_HOST=localhost \ | ||
| 53 | --env API_KEY=secret | ||
| 54 | 35 | ||
| 55 | # Or from an env file | 36 | ``` |
| 56 | ship --binary ./myapp --domain api.example.com \ | 37 | ship host init --host user@your-vps |
| 57 | --env-file .env.production | ||
| 58 | ``` | 38 | ``` |
| 59 | 39 | ||
| 60 | ### 3. Deploy a Static Site | 40 | ### 2. Deploy |
| 61 | 41 | ||
| 62 | ```bash | 42 | **Git push (Docker-based app):** |
| 63 | # Build your site | ||
| 64 | npm run build | ||
| 65 | 43 | ||
| 66 | # Deploy it | 44 | ``` |
| 67 | ship --static --dir ./dist --domain example.com | 45 | ship init myapp |
| 68 | ``` | 46 | ``` |
| 69 | 47 | ||
| 70 | ## App Requirements | 48 | This creates a bare git repo on the VPS, generates `.ship/Caddyfile` and `.ship/service` locally, initializes a local git repo if needed, and adds an `origin` remote. |
| 71 | 49 | ||
| 72 | Your Go app must: | 50 | ``` |
| 73 | 1. Listen on HTTP (not HTTPS - Caddy handles that) | 51 | git add .ship/ Dockerfile |
| 74 | 2. Accept port via `--port` flag or `PORT` environment variable | 52 | git commit -m "initial deploy" |
| 75 | 3. Bind to `localhost` or `127.0.0.1` only | 53 | git push origin main |
| 54 | ``` | ||
| 76 | 55 | ||
| 77 | Example: | 56 | The post-receive hook checks out code, installs `.ship/` configs, runs `docker build`, and restarts the service. If no Dockerfile is present, the push is accepted but deploy is skipped — useful for Go modules and libraries that only need vanity imports. |
| 78 | 57 | ||
| 79 | ```go | 58 | **Git push (static site):** |
| 80 | package main | ||
| 81 | 59 | ||
| 82 | import ( | 60 | ``` |
| 83 | "flag" | 61 | ship init mysite --static |
| 84 | "fmt" | 62 | git add .ship/ index.html |
| 85 | "net/http" | 63 | git commit -m "initial deploy" |
| 86 | "os" | 64 | git push origin main |
| 87 | ) | 65 | ``` |
| 88 | 66 | ||
| 89 | func main() { | 67 | **Direct (pre-built binary):** |
| 90 | port := flag.String("port", os.Getenv("PORT"), "port to listen on") | ||
| 91 | flag.Parse() | ||
| 92 | 68 | ||
| 93 | if *port == "" { | 69 | ``` |
| 94 | *port = "8080" // fallback for local dev | 70 | GOOS=linux GOARCH=amd64 go build -o myapp |
| 95 | } | 71 | ship --binary ./myapp --domain api.example.com |
| 72 | ``` | ||
| 96 | 73 | ||
| 97 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | 74 | **Direct (static site):** |
| 98 | w.Write([]byte("Hello World")) | ||
| 99 | }) | ||
| 100 | 75 | ||
| 101 | addr := "127.0.0.1:" + *port | 76 | ``` |
| 102 | fmt.Printf("Listening on %s\n", addr) | 77 | ship --static --dir ./dist --domain example.com |
| 103 | http.ListenAndServe(addr, nil) | ||
| 104 | } | ||
| 105 | ``` | 78 | ``` |
| 106 | 79 | ||
| 107 | ## Commands | 80 | ## Commands |
| 108 | 81 | ||
| 109 | ### Host Management | 82 | ### `ship init <name>` |
| 83 | |||
| 84 | Create a bare git repo on the VPS and generate local `.ship/` config files. | ||
| 85 | |||
| 86 | ``` | ||
| 87 | ship init myapp # Docker-based app | ||
| 88 | ship init mysite --static # static site | ||
| 89 | ship init myapp --domain custom.example.com # custom domain | ||
| 90 | ship init mylib --public # publicly cloneable (for go get) | ||
| 91 | ``` | ||
| 110 | 92 | ||
| 111 | ```bash | 93 | Flags: |
| 112 | # Initialize a fresh VPS (one-time setup, sets as default) | 94 | - `--static` — initialize as a static site instead of a Docker app |
| 113 | ship host init user@vps-ip | 95 | - `--public` — make the repo publicly cloneable over HTTPS |
| 96 | - `--domain` — custom domain (default: `name.basedomain`) | ||
| 114 | 97 | ||
| 115 | # Update system packages (apt update && apt upgrade) | 98 | ### `ship deploy <name>` |
| 116 | ship host update | ||
| 117 | 99 | ||
| 118 | # Check host status | 100 | Manually rebuild and deploy a git-deployed app. Runs the same steps as the post-receive hook: checkout, install configs, docker build, restart. |
| 119 | ship host status | ||
| 120 | 101 | ||
| 121 | # SSH into the host | ||
| 122 | ship host ssh | ||
| 123 | ``` | 102 | ``` |
| 103 | ship deploy myapp | ||
| 104 | ``` | ||
| 105 | |||
| 106 | ### `ship [deploy flags]` | ||
| 124 | 107 | ||
| 125 | ### Deploy App/Site | 108 | Deploy a pre-built binary or static directory directly. |
| 126 | ```bash | 109 | |
| 127 | # Go app | 110 | ``` |
| 111 | # App | ||
| 128 | ship --binary ./myapp --domain api.example.com | 112 | ship --binary ./myapp --domain api.example.com |
| 113 | ship --binary ./myapp --domain api.example.com --env DB_HOST=localhost --env API_KEY=secret | ||
| 114 | ship --binary ./myapp --domain api.example.com --env-file .env.production | ||
| 115 | ship --binary ./myapp --name myapi --memory 512M --cpu 50% | ||
| 129 | 116 | ||
| 130 | # Static site | 117 | # Static site |
| 131 | ship --static --dir ./dist --domain example.com | 118 | ship --static --dir ./dist --domain example.com |
| 132 | 119 | ||
| 133 | # Custom name (defaults to binary/directory name) | 120 | # Config update (no binary, just change settings) |
| 134 | ship --name myapi --binary ./myapp --domain api.example.com | 121 | ship --name myapi --memory 1G |
| 122 | ship --name myapi --env DEBUG=true | ||
| 135 | ``` | 123 | ``` |
| 136 | 124 | ||
| 137 | ### List Deployments | 125 | Flags: |
| 138 | ```bash | 126 | - `--binary` — path to a compiled binary |
| 139 | ship list | 127 | - `--static` — deploy as a static site |
| 128 | - `--dir` — directory to deploy (default: `.`) | ||
| 129 | - `--domain` — custom domain | ||
| 130 | - `--name` — app name (default: inferred from binary or directory) | ||
| 131 | - `--env KEY=VALUE` — environment variable (repeatable) | ||
| 132 | - `--env-file` — path to a `.env` file | ||
| 133 | - `--args` — arguments passed to the binary | ||
| 134 | - `--file` — config file to upload to working directory (repeatable) | ||
| 135 | - `--memory` — memory limit (e.g., `512M`, `1G`) | ||
| 136 | - `--cpu` — CPU limit (e.g., `50%`, `200%` for 2 cores) | ||
| 137 | |||
| 138 | ### `ship list` | ||
| 139 | |||
| 140 | List all deployments on the default host. | ||
| 141 | |||
| 142 | ``` | ||
| 143 | NAME TYPE VISIBILITY DOMAIN PORT | ||
| 144 | myapp git-app private myapp.example.com :8001 | ||
| 145 | mysite git-static public mysite.example.com | ||
| 146 | api app api.example.com :8002 | ||
| 140 | ``` | 147 | ``` |
| 141 | 148 | ||
| 142 | ### Manage Deployments | 149 | ### `ship status <name>` |
| 143 | ```bash | ||
| 144 | # View logs | ||
| 145 | ship logs myapp | ||
| 146 | 150 | ||
| 147 | # View status | 151 | Show systemd service status for an app. |
| 148 | ship status myapp | ||
| 149 | 152 | ||
| 150 | # Restart app | 153 | ### `ship logs <name>` |
| 151 | ship restart myapp | ||
| 152 | 154 | ||
| 153 | # Remove deployment | 155 | Show service logs (via journalctl). |
| 154 | ship remove myapp | 156 | |
| 155 | ``` | 157 | ### `ship restart <name>` |
| 158 | |||
| 159 | Restart an app's systemd service. | ||
| 160 | |||
| 161 | ### `ship remove <name>` | ||
| 156 | 162 | ||
| 157 | ### Environment Variables | 163 | Remove a deployment. Stops the service, removes files, configs, and state. |
| 158 | ```bash | ||
| 159 | # View current env vars (secrets are masked) | ||
| 160 | ship env list myapi | ||
| 161 | 164 | ||
| 162 | # Set env vars | 165 | ### `ship env` |
| 163 | ship env set myapi DB_HOST=localhost API_KEY=secret | ||
| 164 | 166 | ||
| 165 | # Load from file | 167 | Manage environment variables for an app. |
| 166 | ship env set myapi -f .env.production | ||
| 167 | 168 | ||
| 168 | # Unset env var | 169 | ``` |
| 169 | ship env unset myapi API_KEY | 170 | ship env list myapp # show env vars (secrets masked) |
| 171 | ship env set myapp KEY=VALUE # set variable(s) | ||
| 172 | ship env set myapp -f .env # load from file | ||
| 173 | ship env unset myapp KEY # remove a variable | ||
| 170 | ``` | 174 | ``` |
| 171 | 175 | ||
| 172 | ## Configuration | 176 | ### `ship host` |
| 173 | 177 | ||
| 174 | The host you initialize becomes the default, so you don't need to specify `--host` for every command. The default host is stored in `~/.config/ship/state.json`. | 178 | Manage the VPS. |
| 175 | 179 | ||
| 176 | ## How It Works | 180 | ``` |
| 181 | ship host init --host user@vps --base-domain example.com # one-time setup | ||
| 182 | ship host status # uptime, disk, memory, load | ||
| 183 | ship host update # apt update && upgrade | ||
| 184 | ship host ssh # open an SSH session | ||
| 185 | ship host set-domain example.com # change base domain | ||
| 186 | ``` | ||
| 177 | 187 | ||
| 178 | 1. **State on Laptop**: All deployment state lives at `~/.config/ship/state.json` on your laptop | 188 | ### `ship ui` |
| 179 | 2. **SSH Orchestration**: The CLI uses SSH to run commands on your VPS | ||
| 180 | 3. **File Transfer**: Binaries transferred via SCP, static sites via rsync | ||
| 181 | 4. **Caddy for HTTPS**: Caddy automatically handles HTTPS certificates | ||
| 182 | 5. **Systemd for Processes**: Apps run as systemd services with auto-restart | ||
| 183 | 6. **Dumb VPS**: The VPS is stateless - you can recreate it by redeploying from local state | ||
| 184 | 189 | ||
| 185 | ## File Structure | 190 | Launch a local web UI for viewing deployments. |
| 186 | 191 | ||
| 187 | ### On Laptop | ||
| 188 | ``` | 192 | ``` |
| 189 | ~/.config/ship/state.json # All deployment state (including default host) | 193 | ship ui # http://localhost:8080 |
| 194 | ship ui -p 3000 # custom port | ||
| 190 | ``` | 195 | ``` |
| 191 | 196 | ||
| 192 | ### On VPS | 197 | ### `ship version` |
| 198 | |||
| 199 | Show version, commit, and build date. | ||
| 200 | |||
| 201 | ## How it works | ||
| 202 | |||
| 203 | ### Architecture | ||
| 204 | |||
| 205 | Ship is a client-side CLI. All state lives on your laptop at `~/.config/ship/state.json`. The VPS is configured entirely over SSH — no agent or daemon runs on the server. This means the VPS is stateless and easily recreatable from local state. | ||
| 206 | |||
| 207 | ### Git push flow | ||
| 208 | |||
| 209 | 1. `ship init` creates a bare repo at `/srv/git/<name>.git` with a post-receive hook | ||
| 210 | 2. `git push` triggers the hook, which: | ||
| 211 | - Checks out code to `/var/lib/<name>/src` | ||
| 212 | - Copies `.ship/service` to `/etc/systemd/system/<name>.service` | ||
| 213 | - Copies `.ship/Caddyfile` to `/etc/caddy/sites-enabled/<name>.caddy` | ||
| 214 | - Runs `docker build` (skipped if no Dockerfile) | ||
| 215 | - Restarts the systemd service | ||
| 216 | 3. The Docker container runs with: | ||
| 217 | - Port bound to `127.0.0.1:<port>` | ||
| 218 | - Env vars from `/etc/ship/env/<name>.env` | ||
| 219 | - Persistent data volume at `/var/lib/<name>/data` (mounted as `/data`) | ||
| 220 | 4. Caddy reverse-proxies HTTPS traffic to the container | ||
| 221 | |||
| 222 | ### Direct deploy flow | ||
| 223 | |||
| 224 | 1. Binary uploaded via SCP to `/usr/local/bin/<name>` | ||
| 225 | 2. A dedicated system user is created | ||
| 226 | 3. Ship generates and installs a systemd unit and Caddy config | ||
| 227 | 4. Service is started, Caddy is reloaded | ||
| 228 | |||
| 229 | ### Vanity imports and git cloning | ||
| 230 | |||
| 231 | When a base domain is configured, Ship sets up the base domain to serve: | ||
| 232 | |||
| 233 | - **Go vanity imports** — `go get example.com/myapp` returns the correct `<meta go-import>` tag pointing to `https://example.com/myapp.git` | ||
| 234 | - **Git HTTPS cloning** — `git clone https://example.com/myapp.git` works for public repos (those created with `--public`) | ||
| 235 | |||
| 236 | This is handled by Caddy's `templates` directive and `fcgiwrap` + `git-http-backend`, with no custom server. | ||
| 237 | |||
| 238 | ### Port allocation | ||
| 239 | |||
| 240 | Ports are allocated automatically starting from 8001 and never reused. You don't need to track which ports are in use. | ||
| 241 | |||
| 242 | ## VPS file layout | ||
| 243 | |||
| 193 | ``` | 244 | ``` |
| 194 | /usr/local/bin/myapp # Go binary | 245 | /srv/git/<name>.git/ # bare git repos |
| 195 | /var/lib/myapp/ # Working directory | 246 | /srv/git/<name>.git/hooks/post-receive # auto-deploy hook |
| 196 | /etc/systemd/system/myapp.service # Systemd unit | 247 | |
| 197 | /etc/caddy/sites-enabled/myapp.caddy # Caddy config | 248 | /var/lib/<name>/src/ # checked-out source (for docker build) |
| 198 | /etc/ship/env/myapp.env # Environment variables | 249 | /var/lib/<name>/data/ # persistent data volume |
| 250 | /etc/systemd/system/<name>.service # systemd unit | ||
| 251 | /etc/caddy/sites-enabled/<name>.caddy # Caddy config | ||
| 252 | /etc/ship/env/<name>.env # environment variables | ||
| 199 | 253 | ||
| 200 | /var/www/mysite/ # Static site files | 254 | /var/www/<name>/ # static site files |
| 201 | /etc/caddy/sites-enabled/mysite.caddy # Caddy config | 255 | |
| 256 | /usr/local/bin/<name> # direct-deployed binaries | ||
| 257 | |||
| 258 | /opt/ship/vanity/index.html # vanity import template | ||
| 259 | /etc/caddy/sites-enabled/ship-code.caddy # base domain Caddy config | ||
| 260 | /etc/sudoers.d/ship-git # sudo rules for git user | ||
| 261 | /home/git/.ssh/authorized_keys # SSH keys for git push | ||
| 202 | ``` | 262 | ``` |
| 203 | 263 | ||
| 204 | ## Security | 264 | ## App requirements |
| 265 | |||
| 266 | For direct-deployed Go apps, the binary must: | ||
| 205 | 267 | ||
| 206 | - Each Go app runs as dedicated system user | 268 | 1. Listen on HTTP (Caddy handles HTTPS) |
| 207 | - Systemd security hardening enabled (NoNewPrivileges, PrivateTmp) | 269 | 2. Read the port from the `PORT` environment variable or a `--port` flag |
| 208 | - Static sites served as www-data | 270 | 3. Bind to `127.0.0.1` (not `0.0.0.0`) |
| 209 | - Caddy automatically manages TLS certificates | ||
| 210 | - Environment files stored with 0600 permissions | ||
| 211 | - Secrets masked when displaying environment variables | ||
| 212 | 271 | ||
| 213 | ## Supported OS | 272 | For git-deployed Docker apps, the Dockerfile should expose a service that listens on the port specified by the `PORT` environment variable. |
| 273 | |||
| 274 | ## Supported platforms | ||
| 275 | |||
| 276 | VPS: Ubuntu 20.04+ or Debian 11+ | ||
| 277 | |||
| 278 | ## Security | ||
| 214 | 279 | ||
| 215 | - Ubuntu 20.04+ | 280 | See [SECURITY.md](SECURITY.md) for the threat model, mitigations, and known gaps. |
| 216 | - Debian 11+ | ||
| 217 | 281 | ||
| 218 | ## License | 282 | ## License |
| 219 | 283 | ||
