summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-10 21:29:32 -0800
committerbndw <ben@bdw.to>2026-02-10 21:29:32 -0800
commitf5b667c80e49117c94481d49c5b0c77dbcf2804a (patch)
treeb9b6d8f4cca99639b47fecacebefcd769ba48ed7
parentc49a067ac84ac5c1691ecf4db6a9bf791246899f (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.
-rw-r--r--README.md350
-rw-r--r--SECURITY.md55
2 files changed, 262 insertions, 143 deletions
diff --git a/README.md b/README.md
index 4394e35..54be72f 100644
--- a/README.md
+++ b/README.md
@@ -1,219 +1,283 @@
1# Ship - VPS Deployment CLI 1# Ship
2 2
3Simple CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy. 3Ship 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 5There 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 10If 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
20go build -o ~/bin/ship ./cmd/ship
21 13
22# Or install to GOPATH 14```
23go install ./cmd/ship 15go install github.com/bdw/ship/cmd/ship@latest
24``` 16```
25 17
26## Quick Start 18Or 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) 21go build -o ship ./cmd/ship
32ship host init user@your-vps-ip
33``` 22```
34 23
35This 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 29ship host init --host user@your-vps --base-domain example.com
45GOOS=linux GOARCH=amd64 go build -o myapp 30```
46 31
47# Deploy it 32This 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.
48ship --binary ./myapp --domain api.example.com
49 33
50# With environment variables 34If you don't need git-push deploys or vanity imports, omit `--base-domain`:
51ship --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```
56ship --binary ./myapp --domain api.example.com \ 37ship 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
64npm run build
65 43
66# Deploy it 44```
67ship --static --dir ./dist --domain example.com 45ship init myapp
68``` 46```
69 47
70## App Requirements 48This 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
72Your Go app must: 50```
731. Listen on HTTP (not HTTPS - Caddy handles that) 51git add .ship/ Dockerfile
742. Accept port via `--port` flag or `PORT` environment variable 52git commit -m "initial deploy"
753. Bind to `localhost` or `127.0.0.1` only 53git push origin main
54```
76 55
77Example: 56The 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):**
80package main
81 59
82import ( 60```
83 "flag" 61ship init mysite --static
84 "fmt" 62git add .ship/ index.html
85 "net/http" 63git commit -m "initial deploy"
86 "os" 64git push origin main
87) 65```
88 66
89func 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 70GOOS=linux GOARCH=amd64 go build -o myapp
95 } 71ship --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) 77ship --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
84Create a bare git repo on the VPS and generate local `.ship/` config files.
85
86```
87ship init myapp # Docker-based app
88ship init mysite --static # static site
89ship init myapp --domain custom.example.com # custom domain
90ship init mylib --public # publicly cloneable (for go get)
91```
110 92
111```bash 93Flags:
112# Initialize a fresh VPS (one-time setup, sets as default) 94- `--static` — initialize as a static site instead of a Docker app
113ship 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>`
116ship host update
117 99
118# Check host status 100Manually rebuild and deploy a git-deployed app. Runs the same steps as the post-receive hook: checkout, install configs, docker build, restart.
119ship host status
120 101
121# SSH into the host
122ship host ssh
123``` 102```
103ship deploy myapp
104```
105
106### `ship [deploy flags]`
124 107
125### Deploy App/Site 108Deploy a pre-built binary or static directory directly.
126```bash 109
127# Go app 110```
111# App
128ship --binary ./myapp --domain api.example.com 112ship --binary ./myapp --domain api.example.com
113ship --binary ./myapp --domain api.example.com --env DB_HOST=localhost --env API_KEY=secret
114ship --binary ./myapp --domain api.example.com --env-file .env.production
115ship --binary ./myapp --name myapi --memory 512M --cpu 50%
129 116
130# Static site 117# Static site
131ship --static --dir ./dist --domain example.com 118ship --static --dir ./dist --domain example.com
132 119
133# Custom name (defaults to binary/directory name) 120# Config update (no binary, just change settings)
134ship --name myapi --binary ./myapp --domain api.example.com 121ship --name myapi --memory 1G
122ship --name myapi --env DEBUG=true
135``` 123```
136 124
137### List Deployments 125Flags:
138```bash 126- `--binary` — path to a compiled binary
139ship 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
140List all deployments on the default host.
141
142```
143NAME TYPE VISIBILITY DOMAIN PORT
144myapp git-app private myapp.example.com :8001
145mysite git-static public mysite.example.com
146api app api.example.com :8002
140``` 147```
141 148
142### Manage Deployments 149### `ship status <name>`
143```bash
144# View logs
145ship logs myapp
146 150
147# View status 151Show systemd service status for an app.
148ship status myapp
149 152
150# Restart app 153### `ship logs <name>`
151ship restart myapp
152 154
153# Remove deployment 155Show service logs (via journalctl).
154ship remove myapp 156
155``` 157### `ship restart <name>`
158
159Restart an app's systemd service.
160
161### `ship remove <name>`
156 162
157### Environment Variables 163Remove a deployment. Stops the service, removes files, configs, and state.
158```bash
159# View current env vars (secrets are masked)
160ship env list myapi
161 164
162# Set env vars 165### `ship env`
163ship env set myapi DB_HOST=localhost API_KEY=secret
164 166
165# Load from file 167Manage environment variables for an app.
166ship env set myapi -f .env.production
167 168
168# Unset env var 169```
169ship env unset myapi API_KEY 170ship env list myapp # show env vars (secrets masked)
171ship env set myapp KEY=VALUE # set variable(s)
172ship env set myapp -f .env # load from file
173ship env unset myapp KEY # remove a variable
170``` 174```
171 175
172## Configuration 176### `ship host`
173 177
174The 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`. 178Manage the VPS.
175 179
176## How It Works 180```
181ship host init --host user@vps --base-domain example.com # one-time setup
182ship host status # uptime, disk, memory, load
183ship host update # apt update && upgrade
184ship host ssh # open an SSH session
185ship host set-domain example.com # change base domain
186```
177 187
1781. **State on Laptop**: All deployment state lives at `~/.config/ship/state.json` on your laptop 188### `ship ui`
1792. **SSH Orchestration**: The CLI uses SSH to run commands on your VPS
1803. **File Transfer**: Binaries transferred via SCP, static sites via rsync
1814. **Caddy for HTTPS**: Caddy automatically handles HTTPS certificates
1825. **Systemd for Processes**: Apps run as systemd services with auto-restart
1836. **Dumb VPS**: The VPS is stateless - you can recreate it by redeploying from local state
184 189
185## File Structure 190Launch 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) 193ship ui # http://localhost:8080
194ship ui -p 3000 # custom port
190``` 195```
191 196
192### On VPS 197### `ship version`
198
199Show version, commit, and build date.
200
201## How it works
202
203### Architecture
204
205Ship 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
2091. `ship init` creates a bare repo at `/srv/git/<name>.git` with a post-receive hook
2102. `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
2163. 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`)
2204. Caddy reverse-proxies HTTPS traffic to the container
221
222### Direct deploy flow
223
2241. Binary uploaded via SCP to `/usr/local/bin/<name>`
2252. A dedicated system user is created
2263. Ship generates and installs a systemd unit and Caddy config
2274. Service is started, Caddy is reloaded
228
229### Vanity imports and git cloning
230
231When 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
236This is handled by Caddy's `templates` directive and `fcgiwrap` + `git-http-backend`, with no custom server.
237
238### Port allocation
239
240Ports 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
266For direct-deployed Go apps, the binary must:
205 267
206- Each Go app runs as dedicated system user 2681. Listen on HTTP (Caddy handles HTTPS)
207- Systemd security hardening enabled (NoNewPrivileges, PrivateTmp) 2692. Read the port from the `PORT` environment variable or a `--port` flag
208- Static sites served as www-data 2703. 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 272For 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
276VPS: Ubuntu 20.04+ or Debian 11+
277
278## Security
214 279
215- Ubuntu 20.04+ 280See [SECURITY.md](SECURITY.md) for the threat model, mitigations, and known gaps.
216- Debian 11+
217 281
218## License 282## License
219 283
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..ad04094
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,55 @@
1# Security Model & Known Gaps
2
3Ship is a single-user VPS deployment tool. The threat model assumes:
4- You control the VPS and have root SSH access
5- You trust everyone who has SSH push access (their keys are copied to the `git` user)
6- The VPS runs only your own apps
7
8## Mitigations in place
9
10### App name validation
11All app/project names are validated against `^[a-z][a-z0-9-]{0,62}$` before being used in shell commands, file paths, systemd units, or DNS labels. This prevents command injection via crafted names.
12
13### Scoped sudoers
14The `git` user's sudo rules are restricted to specific paths:
15- `systemctl restart/enable` only for services matching `[a-z]*`
16- `cp` only from `.ship/` subdirectories to `/etc/systemd/system/` and `/etc/caddy/sites-enabled/`
17- `mkdir` only under `/var/lib/` and `/var/www/`
18- `chown` only for `git:git` under `/var/lib/` and `/var/www/`
19
20### Scoped safe.directory
21Git's `safe.directory` is set only for the `www-data` user (not system-wide), preserving CVE-2022-24765 protection for other users.
22
23## Accepted risks (by design)
24
25### SSH key access = root access
26The `git` user is in the `docker` group, which is root-equivalent (can mount the host filesystem). Additionally, `.ship/service` files pushed via git are installed as systemd units. Anyone with SSH push access effectively has root. This is intentional for a single-user tool.
27
28### Git repo visibility
29Repos are private by default (not cloneable over HTTPS). Use `ship init --public` to make a repo publicly cloneable. This is controlled by the `git-daemon-export-ok` marker file in each bare repo. Only public repos are accessible via `go get` or `git clone` over HTTPS.
30
31### User-controlled systemd units
32The `.ship/service` file in each repo is copied to `/etc/systemd/system/` on push. A malicious service file could run arbitrary commands as root. This is equivalent to the Docker access risk above.
33
34## Known gaps (not yet addressed)
35
36### SSH host key verification disabled
37`ssh.InsecureIgnoreHostKey()` is used for all SSH connections, and `StrictHostKeyChecking=no` for scp/rsync. This makes connections vulnerable to MITM attacks on untrusted networks. A future improvement would use `known_hosts` verification.
38
39### Env files may have loose permissions
40Environment files at `/etc/ship/env/{name}.env` are created via `sudo tee` and may be world-readable depending on umask. These files can contain secrets. The `deploy` flow does `chmod 600` but `ship init` does not. A future improvement would ensure consistent restrictive permissions.
41
42### host init is not idempotent
43Running `ship host init` twice will overwrite `/etc/caddy/Caddyfile` and the base domain Caddy config, destroying any manual edits. No guard checks whether setup has already been completed.
44
45### No rollback on failed docker build
46The post-receive hook installs `.ship/service` and `.ship/Caddyfile` before running `docker build`. If the build fails, the configs are updated but the old image is still running, creating a mismatch. The old container keeps running (due to `set -e`), but a manual restart would use the new (mismatched) unit file.
47
48### ship deploy vs git push ownership mismatch
49`ship deploy` runs commands as root (the SSH user), while `git push` triggers the hook as the `git` user. Files checked out by `ship deploy` become root-owned, which can prevent subsequent `git push` deploys from overwriting them.
50
51### No concurrent push protection
52Simultaneous pushes can race on the checkout directory and docker build. For single-user usage this is unlikely but not impossible.
53
54### Port allocation is monotonic
55Ports are never reclaimed when apps are removed. After ~57,000 create/remove cycles, ports would be exhausted. Not a practical concern.