From 13c2f9cffa624fdf498f3b61fab9d809b92e026e Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 28 Dec 2025 09:21:08 -0800 Subject: init --- DESIGN_SPEC.md | 586 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 586 insertions(+) create mode 100644 DESIGN_SPEC.md (limited to 'DESIGN_SPEC.md') diff --git a/DESIGN_SPEC.md b/DESIGN_SPEC.md new file mode 100644 index 0000000..e8bb197 --- /dev/null +++ b/DESIGN_SPEC.md @@ -0,0 +1,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 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/deploy/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/deploy/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 < ~/.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/deploy/env/{appname}.env` on VPS for systemd to read: +```bash +# /etc/deploy/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/deploy/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/deploy/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/deploy/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/deploy/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 -- cgit v1.2.3