# 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/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 < ~/.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