summaryrefslogtreecommitdiffstats
path: root/DESIGN_SPEC.md
blob: 51342d44b357fb906547182b2fb6a3afc4139813 (plain)
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
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
# VPS Deployment CLI - Design Spec

## Overview
Standalone CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy.

## Server Setup (One-time)

### Build and Install the Deploy CLI on Your Laptop
```bash
# Clone and build the deploy CLI
git clone <this-repo> deploy
cd deploy
go build -o ~/bin/deploy ./cmd/deploy
# Or: go install ./cmd/deploy (if ~/go/bin is in your PATH)

# Initialize a fresh VPS (one-time)
deploy init --host user@your-vps-ip

# The CLI will SSH into the VPS and:
# - Detect OS (Ubuntu/Debian supported)
# - Install Caddy from official repository
# - Configure Caddy to import `/etc/caddy/sites-enabled/*`
# - Create `/etc/ship/env/` directory for env files
# - Create `/etc/caddy/sites-enabled/` directory
# - Enable and start Caddy service
# - Verify installation
#
# State is stored locally at ~/.config/deploy/state.json
```

## CLI Commands

All commands run from your laptop and use `--host` to specify the VPS.

### Initialize VPS (One-time Setup)
```bash
# On a fresh VPS, run this once to install all dependencies
deploy init --host user@your-vps-ip

# This will SSH to the VPS and:
# - Install Caddy
# - Configure Caddy to use sites-enabled pattern
# - Create /etc/ship/env/ directory for env files
# - Enable and start Caddy
#
# State is stored locally at ~/.config/deploy/state.json
```

### Deploy Go App
```bash
# Build locally, then deploy
deploy --host user@vps-ip --domain api.example.com

# With custom name (defaults to directory name or binary name)
deploy --host user@vps-ip --name myapi --domain api.example.com

# Deploy specific binary
deploy --host user@vps-ip --binary ./myapp --domain api.example.com

# With environment variables
deploy --host user@vps-ip --domain api.example.com --env DB_HOST=localhost --env API_KEY=secret

# Or from env file
deploy --host user@vps-ip --domain api.example.com --env-file .env.production
```

### Deploy Static Site
```bash
# Deploy current directory
deploy --host user@vps-ip --static --domain example.com

# Deploy specific directory
deploy --host user@vps-ip --static --dir ./dist --domain example.com

# With custom name
deploy --host user@vps-ip --static --name mysite --dir ./build --domain example.com
```

### Management Commands
```bash
# List all deployed apps/sites
deploy list --host user@vps-ip

# Remove deployment
deploy remove myapp --host user@vps-ip

# View logs
deploy logs myapp --host user@vps-ip

# Restart app
deploy restart myapp --host user@vps-ip

# View status
deploy status myapp --host user@vps-ip

# Set/update environment variables
deploy env myapi --host user@vps-ip --set DB_HOST=localhost --set API_KEY=secret

# View environment variables (secrets are masked)
deploy env myapi --host user@vps-ip

# Load from file
deploy env myapi --host user@vps-ip --file .env.production

# Remove specific env var
deploy env myapi --host user@vps-ip --unset API_KEY
```

### Optional: Config File
```bash
# Create ~/.config/deploy/config to avoid typing --host every time
mkdir -p ~/.config/deploy
cat > ~/.config/deploy/config <<EOF
host: user@your-vps-ip
EOF

# Now you can omit --host flag
deploy --domain api.example.com
deploy list
```

## Deployment Workflow

### Initial VPS Setup
```bash
# 1. Build the deploy CLI (in this repo)
cd /path/to/deploy-repo
go build -o ~/bin/deploy ./cmd/deploy

# 2. Initialize fresh VPS from your laptop
deploy init --host user@your-vps-ip
# This SSHs to VPS, installs Caddy, creates directories

# 3. Optional: Create config file to avoid typing --host
mkdir -p ~/.config/deploy
echo "host: user@your-vps-ip" > ~/.config/deploy/config

# Done! VPS is ready for deployments
```

### Go Apps
```bash
# 1. Build locally
GOOS=linux GOARCH=amd64 go build -o myapp

# 2. Deploy from laptop (with config file)
deploy --binary ./myapp --domain api.example.com

# Or specify host explicitly
deploy --host user@vps-ip --binary ./myapp --domain api.example.com
# CLI will: scp binary to VPS, set up systemd, configure Caddy, start service
```

### Static Sites
```bash
# 1. Build locally
npm run build

# 2. Deploy from laptop
deploy --static --dir ./dist --domain example.com
# CLI will: rsync files to VPS, configure Caddy
```

## App Requirements

### Go Apps Must:
- Listen on HTTP (standard `http.ListenAndServe`)
- Accept port via env var `PORT` or flag `--port`
- Bind to `localhost` or `127.0.0.1` only (Caddy will handle external traffic)

**Example main.go:**
```go
package main

import (
    "flag"
    "fmt"
    "net/http"
    "os"
)

func main() {
    port := flag.String("port", os.Getenv("PORT"), "port to listen on")
    flag.Parse()
    
    if *port == "" {
        *port = "8080" // fallback for local dev
    }
    
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello World"))
    })
    
    addr := "127.0.0.1:" + *port
    fmt.Printf("Listening on %s\n", addr)
    http.ListenAndServe(addr, nil)
}
```

## CLI Implementation

### How It Works
The CLI is a single binary that runs on your laptop and orchestrates deployments via SSH:

1. **Connect**: Uses SSH to connect to VPS (via `--host` flag or config file)
2. **Transfer**: Copies files to VPS using scp (binaries) or rsync (static sites)
3. **Execute**: Runs commands on VPS over SSH to:
   - Create system users
   - Install systemd units
   - Generate Caddy configs
   - Manage services
4. **Verify**: Checks deployment status and returns results

### Package Structure
```
deploy/
├── cmd/deploy/
│   └── main.go           # CLI entry point
├── internal/
│   ├── ssh/
│   │   └── ssh.go        # SSH connection & command execution
│   ├── init/
│   │   └── init.go       # VPS initialization logic
│   ├── app/
│   │   └── app.go        # Go app deployment logic
│   ├── static/
│   │   └── static.go     # Static site deployment logic
│   ├── systemd/
│   │   └── systemd.go    # Systemd operations
│   ├── caddy/
│   │   └── caddy.go      # Caddy config generation
│   └── config/
│       └── config.go     # Deployment metadata & port allocation
└── templates/
    ├── service.tmpl      # Systemd unit template
    ├── app.caddy.tmpl    # Caddy config for apps
    └── static.caddy.tmpl # Caddy config for static sites
```

### Deployment State (Laptop)
All deployment state stored locally at `~/.config/deploy/state.json`:
```json
{
  "hosts": {
    "user@vps-ip": {
      "next_port": 8003,
      "apps": {
        "myapi": {
          "type": "app",
          "domain": "api.example.com",
          "port": 8001,
          "env": {
            "DB_HOST": "localhost",
            "DB_PORT": "5432",
            "API_KEY": "secret123",
            "ENVIRONMENT": "production"
          }
        },
        "anotherapp": {
          "type": "app",
          "domain": "another.example.com",
          "port": 8002,
          "env": {}
        },
        "mysite": {
          "type": "static",
          "domain": "example.com"
        }
      }
    }
  }
}
```

### Environment Files (VPS)
Environment variables written to `/etc/ship/env/{appname}.env` on VPS for systemd to read:
```bash
# /etc/ship/env/myapi.env (generated from state.json)
PORT=8001
DB_HOST=localhost
DB_PORT=5432
API_KEY=secret123
ENVIRONMENT=production
```

### CLI Behavior

**Host Resolution:**
- CLI runs on your laptop, connects to VPS via SSH
- Host specified via `--host user@vps-ip` flag
- Or read from `~/.config/deploy/config` file if present
- All operations execute on remote VPS over SSH
- Files transferred via scp/rsync

**Init Command:**
- Detects OS (Ubuntu 20.04+, Debian 11+)
- Checks if Caddy is already installed (skip if present)
- Installs Caddy via official APT repository
- Creates `/etc/caddy/Caddyfile` with `import /etc/caddy/sites-enabled/*`
- Creates directory structure: `/etc/ship/env/`, `/etc/caddy/sites-enabled/`
- Enables and starts Caddy
- Runs health check (verify Caddy is running)
- Initializes local state file at `~/.config/deploy/state.json` if not present
- Outputs success message with next steps

**Smart Defaults:**
- App name: infer from `--name` flag, or binary filename, or current directory
- Binary: use `--binary` flag to specify path, or look for binary in current directory
- Port: auto-allocate next available port (starting from 8001)
- Working directory: `/var/lib/{appname}`
- System user: `{appname}`
- Binary location: `/usr/local/bin/{appname}`

**Port Management:**
- Automatically allocates next available port from 8001+
- Tracks allocations in local `~/.config/deploy/state.json`
- User can override with `--port` flag if desired
- Prevents port conflicts

**Validation:**
- Check if domain is already in use (check local state)
- Verify binary exists and is executable (for apps)
- Confirm Caddy is installed and running
- Check for port conflicts (check local state)
- Require root/sudo on VPS

**State Management:**
- All deployment state lives on laptop at `~/.config/deploy/state.json`
- VPS is "dumb" - just runs Caddy, systemd, and deployed apps
- `deploy list` is instant (no SSH needed, reads local state)
- Easy to recreate VPS from scratch by redeploying all apps from local state
- Can manage multiple VPSes from one laptop
- State backed up with your laptop backups
- Trade-off: State can drift if VPS is manually modified (user should use CLI only)

### Installation Steps (Go Apps)

All steps executed remotely on VPS via SSH:

1. Detect app name from `--name` flag, or binary filename, or current directory
2. SCP binary from laptop to VPS temp directory
3. Allocate port (auto-assign next available or use `--port` override)
4. Create system user (e.g., `myapp`)
5. Create working directory (`/var/lib/myapp`)
6. Copy binary to `/usr/local/bin/myapp`
7. Create env file at `/etc/ship/env/myapp.env` with PORT and any user-provided vars
8. Set env file permissions (0600, owned by app user)
9. Generate systemd unit at `/etc/systemd/system/myapp.service` with EnvironmentFile
10. Generate Caddy config at `/etc/caddy/sites-enabled/myapp.caddy` pointing to localhost:port
11. Update local state at `~/.config/deploy/state.json` (on laptop)
12. Enable and start service
13. Reload Caddy

### Installation Steps (Static Sites)

All steps executed remotely on VPS via SSH:

1. Detect site name from `--name` flag or directory name
2. Rsync files from laptop to VPS `/var/www/{sitename}`
3. Set ownership to `www-data:www-data`
4. Generate Caddy config at `/etc/caddy/sites-enabled/{sitename}.caddy`
5. Update local state at `~/.config/deploy/state.json` (on laptop)
6. Reload Caddy

## File Structure

### On Laptop
```
~/.config/deploy/state.json       # All deployment state (apps, ports, env vars)
~/.config/deploy/config           # Optional: default host configuration
```

### On VPS
```
/usr/local/bin/myapp              # Go binary
/var/lib/myapp/                   # Working directory
/etc/systemd/system/myapp.service # Systemd unit
/etc/caddy/sites-enabled/myapp.caddy  # Caddy config
/etc/ship/env/myapp.env         # Environment variables (0600 permissions)

/var/www/mysite/                  # Static site files
/etc/caddy/sites-enabled/mysite.caddy # Caddy config
```

## Templates

### systemd unit (`service.tmpl`)
```ini
[Unit]
Description={{.Name}}
After=network.target

[Service]
Type=simple
User={{.User}}
WorkingDirectory={{.WorkDir}}
EnvironmentFile={{.EnvFile}}
ExecStart={{.BinaryPath}} --port={{.Port}}
Restart=always
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
```

### Caddy config for Go apps (`app.caddy.tmpl`)
```
{{.Domain}} {
    reverse_proxy 127.0.0.1:{{.Port}}
}
```

### Caddy config for static sites (`static.caddy.tmpl`)
```
{{.Domain}} {
    root * {{.RootDir}}
    file_server
    encode gzip
}
```

## Optional Helper Module

For apps that want minimal boilerplate:

```go
// github.com/yourusername/deploy/httpserver

package httpserver

import (
    "flag"
    "fmt"
    "net/http"
    "os"
)

// ListenAndServe starts HTTP server on PORT env var or --port flag
func ListenAndServe(handler http.Handler) error {
    port := flag.String("port", os.Getenv("PORT"), "port to listen on")
    flag.Parse()
    
    if *port == "" {
        *port = "8080" // fallback for local dev
    }
    
    addr := "127.0.0.1:" + *port
    fmt.Printf("Listening on %s\n", addr)
    return http.ListenAndServe(addr, handler)
}
```

## Example Usage Scenarios

### Scenario 1: New Go API (Fresh VPS)
```bash
# ONE-TIME: Initialize fresh VPS from your laptop
deploy init --host user@your-vps-ip
# Installs Caddy, sets up directory structure

# Optional: Save host to config
mkdir -p ~/.config/deploy
echo "host: user@your-vps-ip" > ~/.config/deploy/config

# Develop locally
mkdir myapi && cd myapi
go mod init myapi

# Write standard HTTP app
cat > main.go <<'EOF'
package main
import (
    "flag"
    "fmt"
    "net/http"
    "os"
)
func main() {
    port := flag.String("port", os.Getenv("PORT"), "port to listen on")
    flag.Parse()
    if *port == "" {
        *port = "8080"
    }
    
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello World"))
    })
    
    addr := "127.0.0.1:" + *port
    fmt.Printf("Listening on %s\n", addr)
    http.ListenAndServe(addr, nil)
}
EOF

# Build and deploy from laptop
GOOS=linux GOARCH=amd64 go build
deploy --binary ./myapi --domain api.example.com
# CLI: SCPs binary to VPS, creates systemd service, configures Caddy

# Later, add environment variables from laptop
deploy env myapi --set DB_HOST=localhost --set DB_PORT=5432
# Service automatically restarts to pick up new env vars
```

### Scenario 2: React/Vue/etc Static Site
```bash
# Build
npm run build

# Deploy from laptop
deploy --static --dir ./dist --domain example.com --name mysite
# CLI rsyncs files to VPS, configures Caddy
```

### Scenario 3: Update Existing Deployment
```bash
# Rebuild
GOOS=linux GOARCH=amd64 go build

# Redeploy (same command, it will update)
deploy --binary ./myapi --domain api.example.com
# CLI detects existing deployment, stops service, updates binary, restarts
# Keeps same port allocation
```

### Scenario 4: Multiple Apps
```bash
# Deploy first app from laptop
deploy --binary api1 --domain api1.example.com  # Gets port 8001

# Deploy second app
deploy --binary api2 --domain api2.example.com  # Gets port 8002

# Deploy third app
deploy --binary api3 --domain api3.example.com  # Gets port 8003

# View all deployments from laptop
deploy list
# Output:
# api1    app     api1.example.com    :8001   running
# api2    app     api2.example.com    :8002   running
# api3    app     api3.example.com    :8003   running
```

## Security Considerations

- Run each Go app as dedicated system user
- Use systemd security hardening (NoNewPrivileges, PrivateTmp, ProtectSystem)
- Static sites served as www-data
- Caddy automatically handles TLS cert management
- Environment files stored at `/etc/ship/env/{app}.env` with 0600 permissions
- Env files owned by the app's system user
- `deploy env` command masks sensitive values when displaying (shows `API_KEY=***`)
- Consider using external secret management for production (out of scope for v1)

## Features

✓ Single command deployment from anywhere
✓ Automatic HTTPS via Caddy + Let's Encrypt
✓ Automatic port allocation (no manual tracking needed)
✓ Standard HTTP - write apps the normal way
✓ Environment variable management (set via CLI or .env files)
✓ Secure env file storage (0600 permissions)
✓ Systemd process management with auto-restart
✓ Support for multiple apps/sites on one VPS
✓ Manage multiple VPSes from one laptop
✓ State stored locally (VPS is stateless and easily recreatable)
✓ Security hardening by default
✓ Update existing deployments with same command
✓ Clean removal with `deploy remove`
✓ View logs/status of any deployment
✓ Minimal app requirements (just accept PORT env/flag)

## Out of Scope (for v1)

- Database setup/management
- Secrets encryption at rest (env files are protected by file permissions)
- Secret rotation automation
- Log rotation (systemd journald handles this)
- Monitoring/alerting
- Blue/green or zero-downtime deployments
- Automatic updates/CI integration
- Custom systemd unit modifications
- Non-Caddy web servers