diff options
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 | ||
