1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
|
# Ship
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.
There are two deployment modes:
- **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.
- **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.
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.
## Install
```
go install github.com/bdw/ship/cmd/ship@latest
```
Or build from source:
```
go build -o ship ./cmd/ship
```
## Quick start
### 1. Set up the VPS
```
ship host init --host user@your-vps --base-domain example.com
```
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.
If you don't need git-push deploys or vanity imports, omit `--base-domain`:
```
ship host init --host user@your-vps
```
### 2. Deploy
**Git push (Docker-based app):**
```
ship init myapp
```
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.
```
git add .ship/ Dockerfile
git commit -m "initial deploy"
git push origin main
```
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.
**Git push (static site):**
```
ship init mysite --static
git add .ship/ index.html
git commit -m "initial deploy"
git push origin main
```
**Direct (pre-built binary):**
```
GOOS=linux GOARCH=amd64 go build -o myapp
ship --binary ./myapp --domain api.example.com
```
**Direct (static site):**
```
ship --static --dir ./dist --domain example.com
```
## Commands
### `ship init <name>`
Create a bare git repo on the VPS and generate local `.ship/` config files.
```
ship init myapp # Docker-based app
ship init mysite --static # static site
ship init myapp --domain custom.example.com # custom domain
ship init mylib --public # publicly cloneable (for go get)
```
Flags:
- `--static` — initialize as a static site instead of a Docker app
- `--public` — make the repo publicly cloneable over HTTPS
- `--domain` — custom domain (default: `name.basedomain`)
### `ship deploy <name>`
Manually rebuild and deploy a git-deployed app. Runs the same steps as the post-receive hook: checkout, install configs, docker build, restart.
```
ship deploy myapp
```
### `ship [deploy flags]`
Deploy a pre-built binary or static directory directly.
```
# App
ship --binary ./myapp --domain api.example.com
ship --binary ./myapp --domain api.example.com --env DB_HOST=localhost --env API_KEY=secret
ship --binary ./myapp --domain api.example.com --env-file .env.production
ship --binary ./myapp --name myapi --memory 512M --cpu 50%
# Static site
ship --static --dir ./dist --domain example.com
# Config update (no binary, just change settings)
ship --name myapi --memory 1G
ship --name myapi --env DEBUG=true
```
Flags:
- `--binary` — path to a compiled binary
- `--static` — deploy as a static site
- `--dir` — directory to deploy (default: `.`)
- `--domain` — custom domain
- `--name` — app name (default: inferred from binary or directory)
- `--env KEY=VALUE` — environment variable (repeatable)
- `--env-file` — path to a `.env` file
- `--args` — arguments passed to the binary
- `--file` — config file to upload to working directory (repeatable)
- `--memory` — memory limit (e.g., `512M`, `1G`)
- `--cpu` — CPU limit (e.g., `50%`, `200%` for 2 cores)
### `ship list`
List all deployments on the default host.
```
NAME TYPE VISIBILITY DOMAIN PORT
myapp git-app private myapp.example.com :8001
mysite git-static public mysite.example.com
api app api.example.com :8002
```
### `ship status <name>`
Show systemd service status for an app.
### `ship logs <name>`
Show service logs (via journalctl).
### `ship restart <name>`
Restart an app's systemd service.
### `ship remove <name>`
Remove a deployment. Stops the service, removes files, configs, and state.
### `ship env`
Manage environment variables for an app.
```
ship env list myapp # show env vars (secrets masked)
ship env set myapp KEY=VALUE # set variable(s)
ship env set myapp -f .env # load from file
ship env unset myapp KEY # remove a variable
```
### `ship host`
Manage the VPS.
```
ship host init --host user@vps --base-domain example.com # one-time setup
ship host status # uptime, disk, memory, load
ship host update # apt update && upgrade
ship host ssh # open an SSH session
ship host set-domain example.com # change base domain
```
### `ship ui`
Launch a local web UI for viewing deployments.
```
ship ui # http://localhost:8080
ship ui -p 3000 # custom port
```
### `ship version`
Show version, commit, and build date.
## How it works
### Architecture
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.
### Git push flow
1. `ship init` creates a bare repo at `/srv/git/<name>.git` with a post-receive hook
2. `git push` triggers the hook, which:
- Checks out code to `/var/lib/<name>/src`
- Copies `.ship/service` to `/etc/systemd/system/<name>.service`
- Copies `.ship/Caddyfile` to `/etc/caddy/sites-enabled/<name>.caddy`
- Runs `docker build` (skipped if no Dockerfile)
- Restarts the systemd service
3. The Docker container runs with:
- Port bound to `127.0.0.1:<port>`
- Env vars from `/etc/ship/env/<name>.env`
- Persistent data volume at `/var/lib/<name>/data` (mounted as `/data`)
4. Caddy reverse-proxies HTTPS traffic to the container
### Direct deploy flow
1. Binary uploaded via SCP to `/usr/local/bin/<name>`
2. A dedicated system user is created
3. Ship generates and installs a systemd unit and Caddy config
4. Service is started, Caddy is reloaded
### Vanity imports and git cloning
When a base domain is configured, Ship sets up the base domain to serve:
- **Go vanity imports** — `go get example.com/myapp` returns the correct `<meta go-import>` tag pointing to `https://example.com/myapp.git`
- **Git HTTPS cloning** — `git clone https://example.com/myapp.git` works for public repos (those created with `--public`)
This is handled by Caddy's `templates` directive and `fcgiwrap` + `git-http-backend`, with no custom server.
### Port allocation
Ports are allocated automatically starting from 8001 and never reused. You don't need to track which ports are in use.
## VPS file layout
```
/srv/git/<name>.git/ # bare git repos
/srv/git/<name>.git/hooks/post-receive # auto-deploy hook
/var/lib/<name>/src/ # checked-out source (for docker build)
/var/lib/<name>/data/ # persistent data volume
/etc/systemd/system/<name>.service # systemd unit
/etc/caddy/sites-enabled/<name>.caddy # Caddy config
/etc/ship/env/<name>.env # environment variables
/var/www/<name>/ # static site files
/usr/local/bin/<name> # direct-deployed binaries
/opt/ship/vanity/index.html # vanity import template
/etc/caddy/sites-enabled/ship-code.caddy # base domain Caddy config
/etc/sudoers.d/ship-git # sudo rules for git user
/home/git/.ssh/authorized_keys # SSH keys for git push
```
## App requirements
For direct-deployed Go apps, the binary must:
1. Listen on HTTP (Caddy handles HTTPS)
2. Read the port from the `PORT` environment variable or a `--port` flag
3. Bind to `127.0.0.1` (not `0.0.0.0`)
For git-deployed Docker apps, the Dockerfile should expose a service that listens on the port specified by the `PORT` environment variable.
## Supported platforms
VPS: Ubuntu 20.04+ or Debian 11+
## Security
See [SECURITY.md](SECURITY.md) for the threat model, mitigations, and known gaps.
## License
MIT
|