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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
|
# Plan: Git-Centric Deployment with Docker Builds and Vanity Imports
## Context
Ship currently deploys by SCP'ing pre-built binaries to a VPS. This plan replaces that model entirely: every project starts with a **git remote on the VPS**, pushing triggers an automatic `docker build` and deploy via post-receive hooks. Since all repos live at `/srv/git/`, the same domain serves **Go vanity imports** and **git HTTPS cloning**, making `go get yourdomain.com/foo` work with zero extra setup.
Using Docker for builds means the VPS only needs Docker installed — no language-specific toolchains. Any language with a Dockerfile works.
Deployment config (systemd unit, Caddyfile) lives **in the repo** under `.ship/`, making it versioned, customizable, and deployed via `git push` alongside code. Env vars (which may contain secrets) stay managed separately via `ship env`.
The base domain pulls double duty: `example.com/foo` serves vanity imports, `foo.example.com` runs the app.
No backward compatibility with the current binary-upload flow — clean slate.
---
## User Flow
```
ship host init --host myserver --base-domain example.com # one-time VPS setup
ship init myapp # creates bare repo on VPS + .ship/ locally
git add .ship/ Dockerfile && git commit -m "initial deploy" # commit deploy config
git remote add ship git@myserver:/srv/git/myapp.git
git push ship main # installs configs + docker build + deploy
```
To change config (memory limit, Caddy rules, etc.): edit `.ship/service` or `.ship/Caddyfile`, commit, push.
---
## Phase 1: State & Template Rewrite
### 1A. State — `internal/state/state.go`
Simplified structs:
```go
type Host struct {
NextPort int `json:"next_port"`
BaseDomain string `json:"base_domain,omitempty"`
GitSetup bool `json:"git_setup,omitempty"`
Apps map[string]*App `json:"apps"`
}
type App struct {
Type string `json:"type"` // "app" or "static"
Domain string `json:"domain"`
Port int `json:"port,omitempty"` // only for "app"
Env map[string]string `json:"env,omitempty"` // only for "app"
Repo string `json:"repo"` // e.g., "/srv/git/foo.git"
}
```
Removed from App: `Args`, `Files`, `Memory`, `CPU` — these are now in `.ship/service` inside the repo, not in ship's state.
### 1B. Templates — `internal/templates/templates.go`
Replace existing templates with:
1. **`PostReceiveHook(data)`** — app deploy hook (see Hook Detail below)
2. **`PostReceiveHookStatic(data)`** — static site deploy hook
3. **`CodeCaddy(data)`** — base domain Caddy config for vanity imports + git HTTP
4. **`DockerService(data)`** — default systemd unit (generated into `.ship/service` locally)
5. **`AppCaddy(data)`** — default app Caddyfile (generated into `.ship/Caddyfile` locally)
6. **`StaticCaddy(data)`** — default static Caddyfile (generated into `.ship/Caddyfile` locally)
Remove old: `serviceTemplate`, `appCaddyTemplate`, `staticCaddyTemplate` and their render functions (`SystemdService`, `AppCaddy`, `StaticCaddy`).
---
## Phase 2: Caddy-Native Vanity Imports + Git HTTP
Caddy handles everything directly. No separate server binary.
### Vanity imports — Caddy `templates` directive
HTML template at `/opt/ship/vanity/index.html` on VPS:
```html
<!DOCTYPE html>
<html><head>
{{$path := trimPrefix "/" .Req.URL.Path}}
{{$parts := splitList "/" $path}}
{{$module := first $parts}}
<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git">
</head>
<body>go get {{.Host}}/{{$module}}</body>
</html>
```
### Git HTTP — fcgiwrap + git-http-backend
`fcgiwrap` (apt package) bridges Caddy's FastCGI to git's CGI backend.
### Base domain Caddy config
Generated by `templates.CodeCaddy()`, written to `/etc/caddy/sites-enabled/ship-code.caddy`:
```caddyfile
{baseDomain} {
@goget query go-get=1
handle @goget {
root * /opt/ship/vanity
templates
rewrite * /index.html
file_server
}
@git path_regexp "^.*/(HEAD|info/refs|objects/info/[^/]+|git-upload-pack|git-receive-pack)$"
handle @git {
reverse_proxy unix//run/fcgiwrap.socket {
transport fastcgi {
env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend
env GIT_PROJECT_ROOT /srv/git
env GIT_HTTP_EXPORT_ALL 1
env REQUEST_METHOD {method}
env QUERY_STRING {query}
env PATH_INFO {path}
}
}
}
handle {
respond "not found" 404
}
}
```
---
## Phase 3: `host init` Rewrite — `cmd/ship/host/init.go`
Full setup flow:
1. **Detect OS** — Ubuntu/Debian only (existing)
2. **Install Caddy** — (existing)
3. **Configure Caddyfile** — (existing)
4. **Install Docker** — Docker's official apt repo, `apt-get install -y docker-ce docker-ce-cli containerd.io`
5. **Install git + fcgiwrap** — `apt-get install -y git fcgiwrap`
6. **Create `git` user** — `useradd -r -m -d /home/git -s $(which git-shell) git`, `usermod -aG docker git`
7. **Copy SSH keys** — admin user's `authorized_keys` → `/home/git/.ssh/authorized_keys`
8. **Create `/srv/git`** — owned by `git:git`
9. **Write sudoers** — `/etc/sudoers.d/ship-git`: passwordless sudo for `systemctl`, `cp` to config dirs, `mkdir`/`chown`
10. **Create directories** — `/etc/ship/env`, `/etc/caddy/sites-enabled`, `/opt/ship/vanity`
11. **Write vanity template** — `/opt/ship/vanity/index.html`
12. **Write base domain Caddy config** — `/etc/caddy/sites-enabled/ship-code.caddy`
13. **Start services** — `systemctl enable --now docker fcgiwrap caddy`
14. **Set `hostState.GitSetup = true`**, save state
---
## Phase 4: `ship init <name>` — New Command
**New file:** `cmd/ship/init.go`
```
ship init <name> [--static] [--domain custom.example.com]
```
Does **both local and remote work**.
### Remote (on VPS via SSH):
1. Verify `hostState.GitSetup == true`
2. Create bare repo: `sudo -u git git init --bare /srv/git/{name}.git`
3. For apps: create `/var/lib/{name}/data` and `/var/lib/{name}/src`
4. For static: create `/var/www/{name}`
5. Allocate port (apps only)
6. Write env file `/etc/ship/env/{name}.env` with `PORT={port}` and `DATA_DIR=/data` (apps only)
7. Write post-receive hook to `/srv/git/{name}.git/hooks/post-receive`
8. Save state
### Local (in current directory):
9. Create `.ship/` directory
10. Generate `.ship/Caddyfile` (resolved domain, port)
11. For apps: generate `.ship/service` (resolved name, port)
12. Print git remote URL and next steps
---
## Phase 5: `ship deploy <name>` — Manual Rebuild
**New file:** `cmd/ship/deploy_cmd.go`
```
ship deploy <name>
```
SSH in, trigger same steps as post-receive hook. Stream output to terminal.
---
## Phase 6: Rewrite Remaining Commands
### `root.go`
Remove all deploy flags (`--binary`, `--static`, `--dir`, `--port`, `--args`, `--file`, `--memory`, `--cpu`, `--env`, `--env-file`). Root command shows help. Register: `init`, `deploy`, `list`, `logs`, `status`, `restart`, `remove`, `env`, `host`, `ui`, `version`.
### `deploy.go`
**Delete entirely.** The `runDeploy`, `deployApp`, `deployStatic`, `updateAppConfig`, `DeployOptions` — all removed. Replaced by `ship init` + `git push` + `ship deploy`.
### `remove.go`
- `"app"`: stop service, remove systemd unit, `docker rmi {name}:latest`, remove `/var/lib/{name}`, remove `/srv/git/{name}.git`, remove Caddy config, remove env file
- `"static"`: remove `/var/www/{name}`, remove `/srv/git/{name}.git`, remove Caddy config
### `status.go`, `logs.go`, `restart.go`
Simplify type guard: only `"static"` type is rejected (no service to manage).
### `list.go`
Show port for `"app"` type.
### `env/` subcommands
Work the same — manage `/etc/ship/env/{name}.env` on VPS, restart service.
---
## Post-Receive Hook Detail
### App hook:
```bash
#!/bin/bash
set -euo pipefail
REPO=/srv/git/{name}.git
SRC=/var/lib/{name}/src
NAME={name}
while read oldrev newrev refname; do
branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
[ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; }
done
echo "==> Checking out code..."
git --work-tree="$SRC" --git-dir="$REPO" checkout -f main
cd "$SRC"
# Install deployment config from repo
if [ -f .ship/service ]; then
echo "==> Installing systemd unit..."
sudo cp .ship/service /etc/systemd/system/${NAME}.service
sudo systemctl daemon-reload
fi
if [ -f .ship/Caddyfile ]; then
echo "==> Installing Caddy config..."
sudo cp .ship/Caddyfile /etc/caddy/sites-enabled/${NAME}.caddy
sudo systemctl reload caddy
fi
echo "==> Building Docker image..."
docker build -t ${NAME}:latest .
echo "==> Restarting service..."
sudo systemctl restart ${NAME}
echo "==> Deploy complete!"
```
### Static site hook:
```bash
#!/bin/bash
set -euo pipefail
REPO=/srv/git/{name}.git
WEBROOT=/var/www/{name}
NAME={name}
while read oldrev newrev refname; do
branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
[ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; }
done
echo "==> Deploying static site..."
git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main
if [ -f "$WEBROOT/.ship/Caddyfile" ]; then
echo "==> Installing Caddy config..."
sudo cp "$WEBROOT/.ship/Caddyfile" /etc/caddy/sites-enabled/${NAME}.caddy
sudo systemctl reload caddy
fi
echo "==> Deploy complete!"
```
---
## VPS File Layout
```
/srv/git/ # bare repos (owned by git)
/srv/git/{name}.git/ # per-project bare repo
/srv/git/{name}.git/hooks/post-receive # auto-deploy hook
/home/git/.ssh/authorized_keys # SSH keys for git push
/opt/ship/vanity/index.html # Caddy template for vanity imports
/etc/caddy/sites-enabled/ship-code.caddy # base domain Caddy config
/etc/sudoers.d/ship-git # sudo rules for git user
# Per app:
/var/lib/{name}/src/ # checked-out source (for docker build)
/var/lib/{name}/data/ # persistent data (mounted as /data in container)
/etc/systemd/system/{name}.service # installed from .ship/service on push
/etc/caddy/sites-enabled/{name}.caddy # installed from .ship/Caddyfile on push
/etc/ship/env/{name}.env # env vars (managed by ship env, not in git)
# Per static site:
/var/www/{name}/ # checked-out site files
/etc/caddy/sites-enabled/{name}.caddy # installed from .ship/Caddyfile on push
```
---
## Files Changed / Created
| File | Action |
|------|--------|
| `internal/state/state.go` | Rewrite — simplified App struct, add `Repo`, `GitSetup` |
| `internal/templates/templates.go` | Rewrite — replace old templates with new ones |
| `cmd/ship/init.go` | **Create** — `ship init <name>` |
| `cmd/ship/deploy_cmd.go` | **Create** — `ship deploy <name>` |
| `cmd/ship/host/init.go` | Rewrite — add Docker/git/fcgiwrap/vanity/sudoers |
| `cmd/ship/root.go` | Rewrite — remove deploy flags, register new commands |
| `cmd/ship/deploy.go` | **Delete** |
| `cmd/ship/remove.go` | Rewrite — Docker + git cleanup |
| `cmd/ship/list.go` | Simplify |
| `cmd/ship/status.go` | Simplify |
| `cmd/ship/logs.go` | Simplify |
| `cmd/ship/restart.go` | Simplify |
| `cmd/ship/env/*.go` | Minor updates if needed |
---
## Implementation Order
1. Phase 1 — state + templates
2. Phase 3 — `host init` rewrite
3. Phase 4 — `ship init` command
4. Phase 5 — `ship deploy` command
5. Phase 6 — rewrite remaining commands (root, remove, list, status, logs, restart, delete deploy.go)
---
## Verification
1. `ship host init --host myserver --base-domain example.com` — installs Docker, git, fcgiwrap, Caddy, creates git user, writes vanity template + Caddy config
2. `ship init myapp` — creates bare repo on VPS, generates `.ship/service` and `.ship/Caddyfile` locally
3. `git add .ship/ Dockerfile && git commit && git push ship main` — hook installs configs, builds Docker image, app goes live at `https://myapp.example.com`
4. Edit `.ship/service` to add `--memory=512m`, push — service updated
5. `curl -s 'https://example.com/myapp?go-get=1'` — returns `<meta go-import>` tag
6. `go get example.com/myapp` — vanity import → HTTPS clone → works
7. `ship list` — shows myapp
8. `ship logs myapp` / `ship status myapp` / `ship restart myapp` — work
9. `ship env set myapp SECRET=foo` — updates env, restarts
10. `ship deploy myapp` — manually rebuilds
11. `ship remove myapp` — cleans up everything
12. `ship init mysite --static` + push — static site deployed
|