summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore19
-rw-r--r--DESIGN_SPEC.md586
-rw-r--r--README.md221
-rw-r--r--go.mod7
-rw-r--r--go.sum6
-rw-r--r--internal/config/config.go65
-rw-r--r--internal/ssh/client.go357
-rw-r--r--internal/state/state.go146
-rw-r--r--internal/templates/templates.go82
-rw-r--r--templates/app.caddy.tmpl3
-rw-r--r--templates/service.tmpl17
-rw-r--r--templates/static.caddy.tmpl5
-rw-r--r--test/example-website/about.html42
-rw-r--r--test/example-website/index.html46
-rw-r--r--test/example-website/style.css156
15 files changed, 1758 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c23b850
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
1# Binaries
2deploy
3*.exe
4*.dll
5*.so
6*.dylib
7
8# Test binary
9*.test
10
11# Go workspace file
12go.work
13
14# IDE
15.vscode/
16.idea/
17*.swp
18*.swo
19*~
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 @@
1# VPS Deployment CLI - Design Spec
2
3## Overview
4Standalone CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy.
5
6## Server Setup (One-time)
7
8### Build and Install the Deploy CLI on Your Laptop
9```bash
10# Clone and build the deploy CLI
11git clone <this-repo> deploy
12cd deploy
13go build -o ~/bin/deploy ./cmd/deploy
14# Or: go install ./cmd/deploy (if ~/go/bin is in your PATH)
15
16# Initialize a fresh VPS (one-time)
17deploy init --host user@your-vps-ip
18
19# The CLI will SSH into the VPS and:
20# - Detect OS (Ubuntu/Debian supported)
21# - Install Caddy from official repository
22# - Configure Caddy to import `/etc/caddy/sites-enabled/*`
23# - Create `/etc/deploy/env/` directory for env files
24# - Create `/etc/caddy/sites-enabled/` directory
25# - Enable and start Caddy service
26# - Verify installation
27#
28# State is stored locally at ~/.config/deploy/state.json
29```
30
31## CLI Commands
32
33All commands run from your laptop and use `--host` to specify the VPS.
34
35### Initialize VPS (One-time Setup)
36```bash
37# On a fresh VPS, run this once to install all dependencies
38deploy init --host user@your-vps-ip
39
40# This will SSH to the VPS and:
41# - Install Caddy
42# - Configure Caddy to use sites-enabled pattern
43# - Create /etc/deploy/env/ directory for env files
44# - Enable and start Caddy
45#
46# State is stored locally at ~/.config/deploy/state.json
47```
48
49### Deploy Go App
50```bash
51# Build locally, then deploy
52deploy --host user@vps-ip --domain api.example.com
53
54# With custom name (defaults to directory name or binary name)
55deploy --host user@vps-ip --name myapi --domain api.example.com
56
57# Deploy specific binary
58deploy --host user@vps-ip --binary ./myapp --domain api.example.com
59
60# With environment variables
61deploy --host user@vps-ip --domain api.example.com --env DB_HOST=localhost --env API_KEY=secret
62
63# Or from env file
64deploy --host user@vps-ip --domain api.example.com --env-file .env.production
65```
66
67### Deploy Static Site
68```bash
69# Deploy current directory
70deploy --host user@vps-ip --static --domain example.com
71
72# Deploy specific directory
73deploy --host user@vps-ip --static --dir ./dist --domain example.com
74
75# With custom name
76deploy --host user@vps-ip --static --name mysite --dir ./build --domain example.com
77```
78
79### Management Commands
80```bash
81# List all deployed apps/sites
82deploy list --host user@vps-ip
83
84# Remove deployment
85deploy remove myapp --host user@vps-ip
86
87# View logs
88deploy logs myapp --host user@vps-ip
89
90# Restart app
91deploy restart myapp --host user@vps-ip
92
93# View status
94deploy status myapp --host user@vps-ip
95
96# Set/update environment variables
97deploy env myapi --host user@vps-ip --set DB_HOST=localhost --set API_KEY=secret
98
99# View environment variables (secrets are masked)
100deploy env myapi --host user@vps-ip
101
102# Load from file
103deploy env myapi --host user@vps-ip --file .env.production
104
105# Remove specific env var
106deploy env myapi --host user@vps-ip --unset API_KEY
107```
108
109### Optional: Config File
110```bash
111# Create ~/.config/deploy/config to avoid typing --host every time
112mkdir -p ~/.config/deploy
113cat > ~/.config/deploy/config <<EOF
114host: user@your-vps-ip
115EOF
116
117# Now you can omit --host flag
118deploy --domain api.example.com
119deploy list
120```
121
122## Deployment Workflow
123
124### Initial VPS Setup
125```bash
126# 1. Build the deploy CLI (in this repo)
127cd /path/to/deploy-repo
128go build -o ~/bin/deploy ./cmd/deploy
129
130# 2. Initialize fresh VPS from your laptop
131deploy init --host user@your-vps-ip
132# This SSHs to VPS, installs Caddy, creates directories
133
134# 3. Optional: Create config file to avoid typing --host
135mkdir -p ~/.config/deploy
136echo "host: user@your-vps-ip" > ~/.config/deploy/config
137
138# Done! VPS is ready for deployments
139```
140
141### Go Apps
142```bash
143# 1. Build locally
144GOOS=linux GOARCH=amd64 go build -o myapp
145
146# 2. Deploy from laptop (with config file)
147deploy --binary ./myapp --domain api.example.com
148
149# Or specify host explicitly
150deploy --host user@vps-ip --binary ./myapp --domain api.example.com
151# CLI will: scp binary to VPS, set up systemd, configure Caddy, start service
152```
153
154### Static Sites
155```bash
156# 1. Build locally
157npm run build
158
159# 2. Deploy from laptop
160deploy --static --dir ./dist --domain example.com
161# CLI will: rsync files to VPS, configure Caddy
162```
163
164## App Requirements
165
166### Go Apps Must:
167- Listen on HTTP (standard `http.ListenAndServe`)
168- Accept port via env var `PORT` or flag `--port`
169- Bind to `localhost` or `127.0.0.1` only (Caddy will handle external traffic)
170
171**Example main.go:**
172```go
173package main
174
175import (
176 "flag"
177 "fmt"
178 "net/http"
179 "os"
180)
181
182func main() {
183 port := flag.String("port", os.Getenv("PORT"), "port to listen on")
184 flag.Parse()
185
186 if *port == "" {
187 *port = "8080" // fallback for local dev
188 }
189
190 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
191 w.Write([]byte("Hello World"))
192 })
193
194 addr := "127.0.0.1:" + *port
195 fmt.Printf("Listening on %s\n", addr)
196 http.ListenAndServe(addr, nil)
197}
198```
199
200## CLI Implementation
201
202### How It Works
203The CLI is a single binary that runs on your laptop and orchestrates deployments via SSH:
204
2051. **Connect**: Uses SSH to connect to VPS (via `--host` flag or config file)
2062. **Transfer**: Copies files to VPS using scp (binaries) or rsync (static sites)
2073. **Execute**: Runs commands on VPS over SSH to:
208 - Create system users
209 - Install systemd units
210 - Generate Caddy configs
211 - Manage services
2124. **Verify**: Checks deployment status and returns results
213
214### Package Structure
215```
216deploy/
217├── cmd/deploy/
218│ └── main.go # CLI entry point
219├── internal/
220│ ├── ssh/
221│ │ └── ssh.go # SSH connection & command execution
222│ ├── init/
223│ │ └── init.go # VPS initialization logic
224│ ├── app/
225│ │ └── app.go # Go app deployment logic
226│ ├── static/
227│ │ └── static.go # Static site deployment logic
228│ ├── systemd/
229│ │ └── systemd.go # Systemd operations
230│ ├── caddy/
231│ │ └── caddy.go # Caddy config generation
232│ └── config/
233│ └── config.go # Deployment metadata & port allocation
234└── templates/
235 ├── service.tmpl # Systemd unit template
236 ├── app.caddy.tmpl # Caddy config for apps
237 └── static.caddy.tmpl # Caddy config for static sites
238```
239
240### Deployment State (Laptop)
241All deployment state stored locally at `~/.config/deploy/state.json`:
242```json
243{
244 "hosts": {
245 "user@vps-ip": {
246 "next_port": 8003,
247 "apps": {
248 "myapi": {
249 "type": "app",
250 "domain": "api.example.com",
251 "port": 8001,
252 "env": {
253 "DB_HOST": "localhost",
254 "DB_PORT": "5432",
255 "API_KEY": "secret123",
256 "ENVIRONMENT": "production"
257 }
258 },
259 "anotherapp": {
260 "type": "app",
261 "domain": "another.example.com",
262 "port": 8002,
263 "env": {}
264 },
265 "mysite": {
266 "type": "static",
267 "domain": "example.com"
268 }
269 }
270 }
271 }
272}
273```
274
275### Environment Files (VPS)
276Environment variables written to `/etc/deploy/env/{appname}.env` on VPS for systemd to read:
277```bash
278# /etc/deploy/env/myapi.env (generated from state.json)
279PORT=8001
280DB_HOST=localhost
281DB_PORT=5432
282API_KEY=secret123
283ENVIRONMENT=production
284```
285
286### CLI Behavior
287
288**Host Resolution:**
289- CLI runs on your laptop, connects to VPS via SSH
290- Host specified via `--host user@vps-ip` flag
291- Or read from `~/.config/deploy/config` file if present
292- All operations execute on remote VPS over SSH
293- Files transferred via scp/rsync
294
295**Init Command:**
296- Detects OS (Ubuntu 20.04+, Debian 11+)
297- Checks if Caddy is already installed (skip if present)
298- Installs Caddy via official APT repository
299- Creates `/etc/caddy/Caddyfile` with `import /etc/caddy/sites-enabled/*`
300- Creates directory structure: `/etc/deploy/env/`, `/etc/caddy/sites-enabled/`
301- Enables and starts Caddy
302- Runs health check (verify Caddy is running)
303- Initializes local state file at `~/.config/deploy/state.json` if not present
304- Outputs success message with next steps
305
306**Smart Defaults:**
307- App name: infer from `--name` flag, or binary filename, or current directory
308- Binary: use `--binary` flag to specify path, or look for binary in current directory
309- Port: auto-allocate next available port (starting from 8001)
310- Working directory: `/var/lib/{appname}`
311- System user: `{appname}`
312- Binary location: `/usr/local/bin/{appname}`
313
314**Port Management:**
315- Automatically allocates next available port from 8001+
316- Tracks allocations in local `~/.config/deploy/state.json`
317- User can override with `--port` flag if desired
318- Prevents port conflicts
319
320**Validation:**
321- Check if domain is already in use (check local state)
322- Verify binary exists and is executable (for apps)
323- Confirm Caddy is installed and running
324- Check for port conflicts (check local state)
325- Require root/sudo on VPS
326
327**State Management:**
328- All deployment state lives on laptop at `~/.config/deploy/state.json`
329- VPS is "dumb" - just runs Caddy, systemd, and deployed apps
330- `deploy list` is instant (no SSH needed, reads local state)
331- Easy to recreate VPS from scratch by redeploying all apps from local state
332- Can manage multiple VPSes from one laptop
333- State backed up with your laptop backups
334- Trade-off: State can drift if VPS is manually modified (user should use CLI only)
335
336### Installation Steps (Go Apps)
337
338All steps executed remotely on VPS via SSH:
339
3401. Detect app name from `--name` flag, or binary filename, or current directory
3412. SCP binary from laptop to VPS temp directory
3423. Allocate port (auto-assign next available or use `--port` override)
3434. Create system user (e.g., `myapp`)
3445. Create working directory (`/var/lib/myapp`)
3456. Copy binary to `/usr/local/bin/myapp`
3467. Create env file at `/etc/deploy/env/myapp.env` with PORT and any user-provided vars
3478. Set env file permissions (0600, owned by app user)
3489. Generate systemd unit at `/etc/systemd/system/myapp.service` with EnvironmentFile
34910. Generate Caddy config at `/etc/caddy/sites-enabled/myapp.caddy` pointing to localhost:port
35011. Update local state at `~/.config/deploy/state.json` (on laptop)
35112. Enable and start service
35213. Reload Caddy
353
354### Installation Steps (Static Sites)
355
356All steps executed remotely on VPS via SSH:
357
3581. Detect site name from `--name` flag or directory name
3592. Rsync files from laptop to VPS `/var/www/{sitename}`
3603. Set ownership to `www-data:www-data`
3614. Generate Caddy config at `/etc/caddy/sites-enabled/{sitename}.caddy`
3625. Update local state at `~/.config/deploy/state.json` (on laptop)
3636. Reload Caddy
364
365## File Structure
366
367### On Laptop
368```
369~/.config/deploy/state.json # All deployment state (apps, ports, env vars)
370~/.config/deploy/config # Optional: default host configuration
371```
372
373### On VPS
374```
375/usr/local/bin/myapp # Go binary
376/var/lib/myapp/ # Working directory
377/etc/systemd/system/myapp.service # Systemd unit
378/etc/caddy/sites-enabled/myapp.caddy # Caddy config
379/etc/deploy/env/myapp.env # Environment variables (0600 permissions)
380
381/var/www/mysite/ # Static site files
382/etc/caddy/sites-enabled/mysite.caddy # Caddy config
383```
384
385## Templates
386
387### systemd unit (`service.tmpl`)
388```ini
389[Unit]
390Description={{.Name}}
391After=network.target
392
393[Service]
394Type=simple
395User={{.User}}
396WorkingDirectory={{.WorkDir}}
397EnvironmentFile={{.EnvFile}}
398ExecStart={{.BinaryPath}} --port={{.Port}}
399Restart=always
400RestartSec=5s
401NoNewPrivileges=true
402PrivateTmp=true
403
404[Install]
405WantedBy=multi-user.target
406```
407
408### Caddy config for Go apps (`app.caddy.tmpl`)
409```
410{{.Domain}} {
411 reverse_proxy 127.0.0.1:{{.Port}}
412}
413```
414
415### Caddy config for static sites (`static.caddy.tmpl`)
416```
417{{.Domain}} {
418 root * {{.RootDir}}
419 file_server
420 encode gzip
421}
422```
423
424## Optional Helper Module
425
426For apps that want minimal boilerplate:
427
428```go
429// github.com/yourusername/deploy/httpserver
430
431package httpserver
432
433import (
434 "flag"
435 "fmt"
436 "net/http"
437 "os"
438)
439
440// ListenAndServe starts HTTP server on PORT env var or --port flag
441func ListenAndServe(handler http.Handler) error {
442 port := flag.String("port", os.Getenv("PORT"), "port to listen on")
443 flag.Parse()
444
445 if *port == "" {
446 *port = "8080" // fallback for local dev
447 }
448
449 addr := "127.0.0.1:" + *port
450 fmt.Printf("Listening on %s\n", addr)
451 return http.ListenAndServe(addr, handler)
452}
453```
454
455## Example Usage Scenarios
456
457### Scenario 1: New Go API (Fresh VPS)
458```bash
459# ONE-TIME: Initialize fresh VPS from your laptop
460deploy init --host user@your-vps-ip
461# Installs Caddy, sets up directory structure
462
463# Optional: Save host to config
464mkdir -p ~/.config/deploy
465echo "host: user@your-vps-ip" > ~/.config/deploy/config
466
467# Develop locally
468mkdir myapi && cd myapi
469go mod init myapi
470
471# Write standard HTTP app
472cat > main.go <<'EOF'
473package main
474import (
475 "flag"
476 "fmt"
477 "net/http"
478 "os"
479)
480func main() {
481 port := flag.String("port", os.Getenv("PORT"), "port to listen on")
482 flag.Parse()
483 if *port == "" {
484 *port = "8080"
485 }
486
487 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
488 w.Write([]byte("Hello World"))
489 })
490
491 addr := "127.0.0.1:" + *port
492 fmt.Printf("Listening on %s\n", addr)
493 http.ListenAndServe(addr, nil)
494}
495EOF
496
497# Build and deploy from laptop
498GOOS=linux GOARCH=amd64 go build
499deploy --binary ./myapi --domain api.example.com
500# CLI: SCPs binary to VPS, creates systemd service, configures Caddy
501
502# Later, add environment variables from laptop
503deploy env myapi --set DB_HOST=localhost --set DB_PORT=5432
504# Service automatically restarts to pick up new env vars
505```
506
507### Scenario 2: React/Vue/etc Static Site
508```bash
509# Build
510npm run build
511
512# Deploy from laptop
513deploy --static --dir ./dist --domain example.com --name mysite
514# CLI rsyncs files to VPS, configures Caddy
515```
516
517### Scenario 3: Update Existing Deployment
518```bash
519# Rebuild
520GOOS=linux GOARCH=amd64 go build
521
522# Redeploy (same command, it will update)
523deploy --binary ./myapi --domain api.example.com
524# CLI detects existing deployment, stops service, updates binary, restarts
525# Keeps same port allocation
526```
527
528### Scenario 4: Multiple Apps
529```bash
530# Deploy first app from laptop
531deploy --binary api1 --domain api1.example.com # Gets port 8001
532
533# Deploy second app
534deploy --binary api2 --domain api2.example.com # Gets port 8002
535
536# Deploy third app
537deploy --binary api3 --domain api3.example.com # Gets port 8003
538
539# View all deployments from laptop
540deploy list
541# Output:
542# api1 app api1.example.com :8001 running
543# api2 app api2.example.com :8002 running
544# api3 app api3.example.com :8003 running
545```
546
547## Security Considerations
548
549- Run each Go app as dedicated system user
550- Use systemd security hardening (NoNewPrivileges, PrivateTmp, ProtectSystem)
551- Static sites served as www-data
552- Caddy automatically handles TLS cert management
553- Environment files stored at `/etc/deploy/env/{app}.env` with 0600 permissions
554- Env files owned by the app's system user
555- `deploy env` command masks sensitive values when displaying (shows `API_KEY=***`)
556- Consider using external secret management for production (out of scope for v1)
557
558## Features
559
560✓ Single command deployment from anywhere
561✓ Automatic HTTPS via Caddy + Let's Encrypt
562✓ Automatic port allocation (no manual tracking needed)
563✓ Standard HTTP - write apps the normal way
564✓ Environment variable management (set via CLI or .env files)
565✓ Secure env file storage (0600 permissions)
566✓ Systemd process management with auto-restart
567✓ Support for multiple apps/sites on one VPS
568✓ Manage multiple VPSes from one laptop
569✓ State stored locally (VPS is stateless and easily recreatable)
570✓ Security hardening by default
571✓ Update existing deployments with same command
572✓ Clean removal with `deploy remove`
573✓ View logs/status of any deployment
574✓ Minimal app requirements (just accept PORT env/flag)
575
576## Out of Scope (for v1)
577
578- Database setup/management
579- Secrets encryption at rest (env files are protected by file permissions)
580- Secret rotation automation
581- Log rotation (systemd journald handles this)
582- Monitoring/alerting
583- Blue/green or zero-downtime deployments
584- Automatic updates/CI integration
585- Custom systemd unit modifications
586- Non-Caddy web servers
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8125bc9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,221 @@
1# Deploy - VPS Deployment CLI
2
3Simple CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy.
4
5## Features
6
7- Single command deployment from your laptop
8- Automatic HTTPS via Caddy + Let's Encrypt
9- Automatic port allocation (no manual tracking)
10- Environment variable management
11- Systemd process management with auto-restart
12- Support for multiple apps/sites on one VPS
13- State stored locally (VPS is stateless and easily recreatable)
14- Zero dependencies on VPS (just installs Caddy)
15
16## Installation
17
18```bash
19# Build the CLI
20go build -o ~/bin/deploy ./cmd/deploy
21
22# Or install to GOPATH
23go install ./cmd/deploy
24```
25
26## Quick Start
27
28### 1. Initialize Your VPS (One-time)
29
30```bash
31# Initialize a fresh VPS
32deploy init --host user@your-vps-ip
33```
34
35This will:
36- Install Caddy
37- Configure Caddy for automatic HTTPS
38- Create necessary directories
39- Set up the VPS for deployments
40
41### 2. Deploy a Go App
42
43```bash
44# Build your app for Linux
45GOOS=linux GOARCH=amd64 go build -o myapp
46
47# Deploy it
48deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com
49
50# With environment variables
51deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com \
52 --env DB_HOST=localhost \
53 --env API_KEY=secret
54
55# Or from an env file
56deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com \
57 --env-file .env.production
58```
59
60### 3. Deploy a Static Site
61
62```bash
63# Build your site
64npm run build
65
66# Deploy it
67deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com
68```
69
70## App Requirements
71
72Your Go app must:
731. Listen on HTTP (not HTTPS - Caddy handles that)
742. Accept port via `--port` flag or `PORT` environment variable
753. Bind to `localhost` or `127.0.0.1` only
76
77Example:
78
79```go
80package main
81
82import (
83 "flag"
84 "fmt"
85 "net/http"
86 "os"
87)
88
89func main() {
90 port := flag.String("port", os.Getenv("PORT"), "port to listen on")
91 flag.Parse()
92
93 if *port == "" {
94 *port = "8080" // fallback for local dev
95 }
96
97 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
98 w.Write([]byte("Hello World"))
99 })
100
101 addr := "127.0.0.1:" + *port
102 fmt.Printf("Listening on %s\n", addr)
103 http.ListenAndServe(addr, nil)
104}
105```
106
107## Commands
108
109### Initialize VPS
110```bash
111deploy init --host user@vps-ip
112```
113
114### Deploy App/Site
115```bash
116# Go app
117deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com
118
119# Static site
120deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com
121
122# Custom name (defaults to binary/directory name)
123deploy deploy --host user@vps-ip --name myapi --binary ./myapp --domain api.example.com
124```
125
126### List Deployments
127```bash
128deploy list --host user@vps-ip
129```
130
131### Manage Deployments
132```bash
133# View logs
134deploy logs myapp --host user@vps-ip
135
136# View status
137deploy status myapp --host user@vps-ip
138
139# Restart app
140deploy restart myapp --host user@vps-ip
141
142# Remove deployment
143deploy remove myapp --host user@vps-ip
144```
145
146### Environment Variables
147```bash
148# View current env vars (secrets are masked)
149deploy env myapi --host user@vps-ip
150
151# Set env vars
152deploy env myapi --host user@vps-ip --set DB_HOST=localhost --set API_KEY=secret
153
154# Load from file
155deploy env myapi --host user@vps-ip --file .env.production
156
157# Unset env var
158deploy env myapi --host user@vps-ip --unset API_KEY
159```
160
161## Configuration
162
163Create `~/.config/deploy/config` to avoid typing `--host` every time:
164
165```
166host: user@your-vps-ip
167```
168
169Then you can omit the `--host` flag:
170
171```bash
172deploy list
173deploy deploy --binary ./myapp --domain api.example.com
174```
175
176## How It Works
177
1781. **State on Laptop**: All deployment state lives at `~/.config/deploy/state.json` on your laptop
1792. **SSH Orchestration**: The CLI uses SSH to run commands on your VPS
1803. **File Transfer**: Binaries transferred via SCP, static sites via rsync
1814. **Caddy for HTTPS**: Caddy automatically handles HTTPS certificates
1825. **Systemd for Processes**: Apps run as systemd services with auto-restart
1836. **Dumb VPS**: The VPS is stateless - you can recreate it by redeploying from local state
184
185## File Structure
186
187### On Laptop
188```
189~/.config/deploy/state.json # All deployment state
190~/.config/deploy/config # Optional: default host
191```
192
193### On VPS
194```
195/usr/local/bin/myapp # Go binary
196/var/lib/myapp/ # Working directory
197/etc/systemd/system/myapp.service # Systemd unit
198/etc/caddy/sites-enabled/myapp.caddy # Caddy config
199/etc/deploy/env/myapp.env # Environment variables
200
201/var/www/mysite/ # Static site files
202/etc/caddy/sites-enabled/mysite.caddy # Caddy config
203```
204
205## Security
206
207- Each Go app runs as dedicated system user
208- Systemd security hardening enabled (NoNewPrivileges, PrivateTmp)
209- Static sites served as www-data
210- Caddy automatically manages TLS certificates
211- Environment files stored with 0600 permissions
212- Secrets masked when displaying environment variables
213
214## Supported OS
215
216- Ubuntu 20.04+
217- Debian 11+
218
219## License
220
221MIT
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0d29deb
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,7 @@
1module github.com/bdw/deploy
2
3go 1.21
4
5require golang.org/x/crypto v0.31.0
6
7require golang.org/x/sys v0.28.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7fa4005
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
1golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
2golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
3golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
4golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
5golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
6golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..8651aa8
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,65 @@
1package config
2
3import (
4 "bufio"
5 "os"
6 "path/filepath"
7 "strings"
8)
9
10// Config represents the user's configuration
11type Config struct {
12 Host string
13}
14
15// Load reads config from ~/.config/deploy/config
16func Load() (*Config, error) {
17 path := configPath()
18
19 // If file doesn't exist, return empty config
20 if _, err := os.Stat(path); os.IsNotExist(err) {
21 return &Config{}, nil
22 }
23
24 file, err := os.Open(path)
25 if err != nil {
26 return nil, err
27 }
28 defer file.Close()
29
30 cfg := &Config{}
31 scanner := bufio.NewScanner(file)
32 for scanner.Scan() {
33 line := strings.TrimSpace(scanner.Text())
34 if line == "" || strings.HasPrefix(line, "#") {
35 continue
36 }
37
38 parts := strings.SplitN(line, ":", 2)
39 if len(parts) != 2 {
40 continue
41 }
42
43 key := strings.TrimSpace(parts[0])
44 value := strings.TrimSpace(parts[1])
45
46 switch key {
47 case "host":
48 cfg.Host = value
49 }
50 }
51
52 if err := scanner.Err(); err != nil {
53 return nil, err
54 }
55
56 return cfg, nil
57}
58
59func configPath() string {
60 home, err := os.UserHomeDir()
61 if err != nil {
62 return ".deploy-config"
63 }
64 return filepath.Join(home, ".config", "deploy", "config")
65}
diff --git a/internal/ssh/client.go b/internal/ssh/client.go
new file mode 100644
index 0000000..1cd336c
--- /dev/null
+++ b/internal/ssh/client.go
@@ -0,0 +1,357 @@
1package ssh
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "net"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12
13 "golang.org/x/crypto/ssh"
14 "golang.org/x/crypto/ssh/agent"
15)
16
17// Client represents an SSH connection to a remote host
18type Client struct {
19 host string
20 client *ssh.Client
21}
22
23// sshConfig holds SSH configuration for a host
24type sshConfig struct {
25 Host string
26 HostName string
27 User string
28 Port string
29 IdentityFile string
30}
31
32// Connect establishes an SSH connection to the remote host
33// Supports both SSH config aliases (e.g., "myserver") and user@host format
34func Connect(host string) (*Client, error) {
35 var user, addr string
36 var identityFile string
37
38 // Try to read SSH config first
39 cfg, err := readSSHConfig(host)
40 if err == nil && cfg.HostName != "" {
41 // Use SSH config
42 user = cfg.User
43 addr = cfg.HostName
44 if cfg.Port != "" {
45 addr = addr + ":" + cfg.Port
46 } else {
47 addr = addr + ":22"
48 }
49 identityFile = cfg.IdentityFile
50 } else {
51 // Fall back to parsing user@host format
52 parts := strings.SplitN(host, "@", 2)
53 if len(parts) != 2 {
54 return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host)
55 }
56 user = parts[0]
57 addr = parts[1]
58
59 // Add default port if not specified
60 if !strings.Contains(addr, ":") {
61 addr = addr + ":22"
62 }
63 }
64
65 // Build authentication methods
66 var authMethods []ssh.AuthMethod
67
68 // Try identity file from SSH config first
69 if identityFile != "" {
70 if authMethod, err := publicKeyFromFile(identityFile); err == nil {
71 authMethods = append(authMethods, authMethod)
72 }
73 }
74
75 // Try SSH agent
76 if authMethod, err := sshAgent(); err == nil {
77 authMethods = append(authMethods, authMethod)
78 }
79
80 // Try default key files
81 if authMethod, err := publicKeyFile(); err == nil {
82 authMethods = append(authMethods, authMethod)
83 }
84
85 if len(authMethods) == 0 {
86 return nil, fmt.Errorf("no SSH authentication method available")
87 }
88
89 config := &ssh.ClientConfig{
90 User: user,
91 Auth: authMethods,
92 HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts
93 }
94
95 client, err := ssh.Dial("tcp", addr, config)
96 if err != nil {
97 return nil, fmt.Errorf("failed to connect to %s: %w", host, err)
98 }
99
100 return &Client{
101 host: host,
102 client: client,
103 }, nil
104}
105
106// Close closes the SSH connection
107func (c *Client) Close() error {
108 return c.client.Close()
109}
110
111// Run executes a command on the remote host and returns the output
112func (c *Client) Run(cmd string) (string, error) {
113 session, err := c.client.NewSession()
114 if err != nil {
115 return "", err
116 }
117 defer session.Close()
118
119 var stdout, stderr bytes.Buffer
120 session.Stdout = &stdout
121 session.Stderr = &stderr
122
123 if err := session.Run(cmd); err != nil {
124 return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String())
125 }
126
127 return stdout.String(), nil
128}
129
130// RunSudo executes a command with sudo on the remote host
131func (c *Client) RunSudo(cmd string) (string, error) {
132 return c.Run("sudo " + cmd)
133}
134
135// Upload copies a local file to the remote host using scp
136func (c *Client) Upload(localPath, remotePath string) error {
137 // Use external scp command for simplicity
138 // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath
139 cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath)
140
141 var stderr bytes.Buffer
142 cmd.Stderr = &stderr
143
144 if err := cmd.Run(); err != nil {
145 return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String())
146 }
147
148 return nil
149}
150
151// UploadDir copies a local directory to the remote host using rsync
152func (c *Client) UploadDir(localDir, remoteDir string) error {
153 // Use rsync for directory uploads
154 // Format: rsync -avz --delete localDir/ user@host:remoteDir/
155 localDir = strings.TrimSuffix(localDir, "/") + "/"
156 remoteDir = strings.TrimSuffix(remoteDir, "/") + "/"
157
158 cmd := exec.Command("rsync", "-avz", "--delete",
159 "-e", "ssh -o StrictHostKeyChecking=no",
160 localDir, c.host+":"+remoteDir)
161
162 var stderr bytes.Buffer
163 cmd.Stderr = &stderr
164
165 if err := cmd.Run(); err != nil {
166 return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String())
167 }
168
169 return nil
170}
171
172// WriteFile creates a file with the given content on the remote host
173func (c *Client) WriteFile(remotePath, content string) error {
174 session, err := c.client.NewSession()
175 if err != nil {
176 return err
177 }
178 defer session.Close()
179
180 // Use cat to write content to file
181 cmd := fmt.Sprintf("cat > %s", remotePath)
182 session.Stdin = strings.NewReader(content)
183
184 var stderr bytes.Buffer
185 session.Stderr = &stderr
186
187 if err := session.Run(cmd); err != nil {
188 return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String())
189 }
190
191 return nil
192}
193
194// WriteSudoFile creates a file with the given content using sudo
195func (c *Client) WriteSudoFile(remotePath, content string) error {
196 session, err := c.client.NewSession()
197 if err != nil {
198 return err
199 }
200 defer session.Close()
201
202 // Use sudo tee to write content to file
203 cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath)
204 session.Stdin = strings.NewReader(content)
205
206 var stderr bytes.Buffer
207 session.Stderr = &stderr
208
209 if err := session.Run(cmd); err != nil {
210 return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String())
211 }
212
213 return nil
214}
215
216// readSSHConfig reads and parses the SSH config file for a given host
217func readSSHConfig(host string) (*sshConfig, error) {
218 home, err := os.UserHomeDir()
219 if err != nil {
220 return nil, err
221 }
222
223 configPath := filepath.Join(home, ".ssh", "config")
224 file, err := os.Open(configPath)
225 if err != nil {
226 return nil, err
227 }
228 defer file.Close()
229
230 cfg := &sshConfig{}
231 var currentHost string
232 var matchedHost bool
233
234 scanner := bufio.NewScanner(file)
235 for scanner.Scan() {
236 line := strings.TrimSpace(scanner.Text())
237
238 // Skip comments and empty lines
239 if line == "" || strings.HasPrefix(line, "#") {
240 continue
241 }
242
243 fields := strings.Fields(line)
244 if len(fields) < 2 {
245 continue
246 }
247
248 key := strings.ToLower(fields[0])
249 value := fields[1]
250
251 // Expand ~ in paths
252 if strings.HasPrefix(value, "~/") {
253 value = filepath.Join(home, value[2:])
254 }
255
256 switch key {
257 case "host":
258 currentHost = value
259 if currentHost == host {
260 matchedHost = true
261 cfg.Host = host
262 } else {
263 matchedHost = false
264 }
265 case "hostname":
266 if matchedHost {
267 cfg.HostName = value
268 }
269 case "user":
270 if matchedHost {
271 cfg.User = value
272 }
273 case "port":
274 if matchedHost {
275 cfg.Port = value
276 }
277 case "identityfile":
278 if matchedHost {
279 cfg.IdentityFile = value
280 }
281 }
282 }
283
284 if err := scanner.Err(); err != nil {
285 return nil, err
286 }
287
288 if cfg.Host == "" {
289 return nil, fmt.Errorf("host %s not found in SSH config", host)
290 }
291
292 return cfg, nil
293}
294
295// sshAgent returns an auth method using SSH agent
296func sshAgent() (ssh.AuthMethod, error) {
297 socket := os.Getenv("SSH_AUTH_SOCK")
298 if socket == "" {
299 return nil, fmt.Errorf("SSH_AUTH_SOCK not set")
300 }
301
302 conn, err := net.Dial("unix", socket)
303 if err != nil {
304 return nil, fmt.Errorf("failed to connect to SSH agent: %w", err)
305 }
306
307 agentClient := agent.NewClient(conn)
308 return ssh.PublicKeysCallback(agentClient.Signers), nil
309}
310
311// publicKeyFromFile returns an auth method from a specific private key file
312func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) {
313 key, err := os.ReadFile(keyPath)
314 if err != nil {
315 return nil, err
316 }
317
318 signer, err := ssh.ParsePrivateKey(key)
319 if err != nil {
320 return nil, err
321 }
322
323 return ssh.PublicKeys(signer), nil
324}
325
326// publicKeyFile returns an auth method using a private key file
327func publicKeyFile() (ssh.AuthMethod, error) {
328 home, err := os.UserHomeDir()
329 if err != nil {
330 return nil, err
331 }
332
333 // Try common key locations
334 keyPaths := []string{
335 filepath.Join(home, ".ssh", "id_rsa"),
336 filepath.Join(home, ".ssh", "id_ed25519"),
337 filepath.Join(home, ".ssh", "id_ecdsa"),
338 }
339
340 for _, keyPath := range keyPaths {
341 if _, err := os.Stat(keyPath); err == nil {
342 key, err := os.ReadFile(keyPath)
343 if err != nil {
344 continue
345 }
346
347 signer, err := ssh.ParsePrivateKey(key)
348 if err != nil {
349 continue
350 }
351
352 return ssh.PublicKeys(signer), nil
353 }
354 }
355
356 return nil, fmt.Errorf("no SSH private key found")
357}
diff --git a/internal/state/state.go b/internal/state/state.go
new file mode 100644
index 0000000..c7c7dd4
--- /dev/null
+++ b/internal/state/state.go
@@ -0,0 +1,146 @@
1package state
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8)
9
10// State represents the entire local deployment state
11type State struct {
12 Hosts map[string]*Host `json:"hosts"`
13}
14
15// Host represents deployment state for a single VPS
16type Host struct {
17 NextPort int `json:"next_port"`
18 Apps map[string]*App `json:"apps"`
19}
20
21// App represents a deployed application or static site
22type App struct {
23 Type string `json:"type"` // "app" or "static"
24 Domain string `json:"domain"`
25 Port int `json:"port,omitempty"` // only for type="app"
26 Env map[string]string `json:"env,omitempty"` // only for type="app"
27}
28
29const (
30 startPort = 8001
31)
32
33// Load reads state from ~/.config/deploy/state.json
34func Load() (*State, error) {
35 path := statePath()
36
37 // If file doesn't exist, return empty state
38 if _, err := os.Stat(path); os.IsNotExist(err) {
39 return &State{
40 Hosts: make(map[string]*Host),
41 }, nil
42 }
43
44 data, err := os.ReadFile(path)
45 if err != nil {
46 return nil, fmt.Errorf("failed to read state file: %w", err)
47 }
48
49 var state State
50 if err := json.Unmarshal(data, &state); err != nil {
51 return nil, fmt.Errorf("failed to parse state file: %w", err)
52 }
53
54 // Initialize maps if nil
55 if state.Hosts == nil {
56 state.Hosts = make(map[string]*Host)
57 }
58
59 return &state, nil
60}
61
62// Save writes state to ~/.config/deploy/state.json
63func (s *State) Save() error {
64 path := statePath()
65
66 // Ensure directory exists
67 dir := filepath.Dir(path)
68 if err := os.MkdirAll(dir, 0755); err != nil {
69 return fmt.Errorf("failed to create config directory: %w", err)
70 }
71
72 data, err := json.MarshalIndent(s, "", " ")
73 if err != nil {
74 return fmt.Errorf("failed to marshal state: %w", err)
75 }
76
77 if err := os.WriteFile(path, data, 0600); err != nil {
78 return fmt.Errorf("failed to write state file: %w", err)
79 }
80
81 return nil
82}
83
84// GetHost returns the host state, creating it if it doesn't exist
85func (s *State) GetHost(host string) *Host {
86 if s.Hosts[host] == nil {
87 s.Hosts[host] = &Host{
88 NextPort: startPort,
89 Apps: make(map[string]*App),
90 }
91 }
92 if s.Hosts[host].Apps == nil {
93 s.Hosts[host].Apps = make(map[string]*App)
94 }
95 return s.Hosts[host]
96}
97
98// AllocatePort returns the next available port for a host
99func (s *State) AllocatePort(host string) int {
100 h := s.GetHost(host)
101 port := h.NextPort
102 h.NextPort++
103 return port
104}
105
106// AddApp adds or updates an app in the state
107func (s *State) AddApp(host, name string, app *App) {
108 h := s.GetHost(host)
109 h.Apps[name] = app
110}
111
112// RemoveApp removes an app from the state
113func (s *State) RemoveApp(host, name string) error {
114 h := s.GetHost(host)
115 if _, exists := h.Apps[name]; !exists {
116 return fmt.Errorf("app %s not found", name)
117 }
118 delete(h.Apps, name)
119 return nil
120}
121
122// GetApp returns an app from the state
123func (s *State) GetApp(host, name string) (*App, error) {
124 h := s.GetHost(host)
125 app, exists := h.Apps[name]
126 if !exists {
127 return nil, fmt.Errorf("app %s not found", name)
128 }
129 return app, nil
130}
131
132// ListApps returns all apps for a host
133func (s *State) ListApps(host string) map[string]*App {
134 h := s.GetHost(host)
135 return h.Apps
136}
137
138// statePath returns the path to the state file
139func statePath() string {
140 home, err := os.UserHomeDir()
141 if err != nil {
142 // Fallback to current directory (should rarely happen)
143 return ".deploy-state.json"
144 }
145 return filepath.Join(home, ".config", "deploy", "state.json")
146}
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
new file mode 100644
index 0000000..d90163d
--- /dev/null
+++ b/internal/templates/templates.go
@@ -0,0 +1,82 @@
1package templates
2
3import (
4 "bytes"
5 "text/template"
6)
7
8var serviceTemplate = `[Unit]
9Description={{.Name}}
10After=network.target
11
12[Service]
13Type=simple
14User={{.User}}
15WorkingDirectory={{.WorkDir}}
16EnvironmentFile={{.EnvFile}}
17ExecStart={{.BinaryPath}} --port={{.Port}}
18Restart=always
19RestartSec=5s
20NoNewPrivileges=true
21PrivateTmp=true
22
23[Install]
24WantedBy=multi-user.target
25`
26
27var appCaddyTemplate = `{{.Domain}} {
28 reverse_proxy 127.0.0.1:{{.Port}}
29}
30`
31
32var staticCaddyTemplate = `{{.Domain}} {
33 root * {{.RootDir}}
34 file_server
35 encode gzip
36}
37`
38
39// SystemdService generates a systemd service unit file
40func SystemdService(data map[string]string) (string, error) {
41 tmpl, err := template.New("service").Parse(serviceTemplate)
42 if err != nil {
43 return "", err
44 }
45
46 var buf bytes.Buffer
47 if err := tmpl.Execute(&buf, data); err != nil {
48 return "", err
49 }
50
51 return buf.String(), nil
52}
53
54// AppCaddy generates a Caddy config for a Go app
55func AppCaddy(data map[string]string) (string, error) {
56 tmpl, err := template.New("caddy").Parse(appCaddyTemplate)
57 if err != nil {
58 return "", err
59 }
60
61 var buf bytes.Buffer
62 if err := tmpl.Execute(&buf, data); err != nil {
63 return "", err
64 }
65
66 return buf.String(), nil
67}
68
69// StaticCaddy generates a Caddy config for a static site
70func StaticCaddy(data map[string]string) (string, error) {
71 tmpl, err := template.New("caddy").Parse(staticCaddyTemplate)
72 if err != nil {
73 return "", err
74 }
75
76 var buf bytes.Buffer
77 if err := tmpl.Execute(&buf, data); err != nil {
78 return "", err
79 }
80
81 return buf.String(), nil
82}
diff --git a/templates/app.caddy.tmpl b/templates/app.caddy.tmpl
new file mode 100644
index 0000000..505d1d9
--- /dev/null
+++ b/templates/app.caddy.tmpl
@@ -0,0 +1,3 @@
1{{.Domain}} {
2 reverse_proxy 127.0.0.1:{{.Port}}
3}
diff --git a/templates/service.tmpl b/templates/service.tmpl
new file mode 100644
index 0000000..87389f0
--- /dev/null
+++ b/templates/service.tmpl
@@ -0,0 +1,17 @@
1[Unit]
2Description={{.Name}}
3After=network.target
4
5[Service]
6Type=simple
7User={{.User}}
8WorkingDirectory={{.WorkDir}}
9EnvironmentFile={{.EnvFile}}
10ExecStart={{.BinaryPath}} --port={{.Port}}
11Restart=always
12RestartSec=5s
13NoNewPrivileges=true
14PrivateTmp=true
15
16[Install]
17WantedBy=multi-user.target
diff --git a/templates/static.caddy.tmpl b/templates/static.caddy.tmpl
new file mode 100644
index 0000000..d04f6b0
--- /dev/null
+++ b/templates/static.caddy.tmpl
@@ -0,0 +1,5 @@
1{{.Domain}} {
2 root * {{.RootDir}}
3 file_server
4 encode gzip
5}
diff --git a/test/example-website/about.html b/test/example-website/about.html
new file mode 100644
index 0000000..93cba92
--- /dev/null
+++ b/test/example-website/about.html
@@ -0,0 +1,42 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>About - Deploy Test</title>
7 <link rel="stylesheet" href="style.css">
8</head>
9<body>
10 <header>
11 <nav>
12 <h1>Deploy Test</h1>
13 <ul>
14 <li><a href="index.html">Home</a></li>
15 <li><a href="about.html">About</a></li>
16 </ul>
17 </nav>
18 </header>
19
20 <main>
21 <section class="about">
22 <h2>About This Site</h2>
23 <p>This is a test website created to demonstrate the deploy tool's static site deployment capabilities.</p>
24
25 <h3>Features</h3>
26 <ul>
27 <li>Simple HTML/CSS structure</li>
28 <li>Multiple pages for testing navigation</li>
29 <li>Responsive design</li>
30 <li>Clean and minimal styling</li>
31 </ul>
32
33 <h3>Deployment Command</h3>
34 <pre><code>./deploy deploy --host peerfile --static --dir ./test/example-website --domain example.com</code></pre>
35 </section>
36 </main>
37
38 <footer>
39 <p>&copy; 2025 Deploy Test. Built for testing purposes.</p>
40 </footer>
41</body>
42</html>
diff --git a/test/example-website/index.html b/test/example-website/index.html
new file mode 100644
index 0000000..735ae73
--- /dev/null
+++ b/test/example-website/index.html
@@ -0,0 +1,46 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Example Website - Deploy Test</title>
7 <link rel="stylesheet" href="style.css">
8</head>
9<body>
10 <header>
11 <nav>
12 <h1>Deploy Test</h1>
13 <ul>
14 <li><a href="index.html">Home</a></li>
15 <li><a href="about.html">About</a></li>
16 </ul>
17 </nav>
18 </header>
19
20 <main>
21 <section class="hero">
22 <h2>Welcome to the Example Website</h2>
23 <p>This is a simple static website for testing the deploy tool.</p>
24 </section>
25
26 <section class="features">
27 <div class="feature">
28 <h3>Fast Deployment</h3>
29 <p>Deploy your static sites in seconds with a single command.</p>
30 </div>
31 <div class="feature">
32 <h3>HTTPS Enabled</h3>
33 <p>Automatic SSL certificates with Caddy.</p>
34 </div>
35 <div class="feature">
36 <h3>Simple Management</h3>
37 <p>Easy-to-use CLI for managing your deployments.</p>
38 </div>
39 </section>
40 </main>
41
42 <footer>
43 <p>&copy; 2025 Deploy Test. Built for testing purposes.</p>
44 </footer>
45</body>
46</html>
diff --git a/test/example-website/style.css b/test/example-website/style.css
new file mode 100644
index 0000000..da7fd1c
--- /dev/null
+++ b/test/example-website/style.css
@@ -0,0 +1,156 @@
1* {
2 margin: 0;
3 padding: 0;
4 box-sizing: border-box;
5}
6
7body {
8 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9 line-height: 1.6;
10 color: #333;
11 background: #f5f5f5;
12}
13
14header {
15 background: #2c3e50;
16 color: white;
17 padding: 1rem 0;
18 box-shadow: 0 2px 5px rgba(0,0,0,0.1);
19}
20
21nav {
22 max-width: 1200px;
23 margin: 0 auto;
24 padding: 0 2rem;
25 display: flex;
26 justify-content: space-between;
27 align-items: center;
28}
29
30nav h1 {
31 font-size: 1.5rem;
32}
33
34nav ul {
35 list-style: none;
36 display: flex;
37 gap: 2rem;
38}
39
40nav a {
41 color: white;
42 text-decoration: none;
43 transition: opacity 0.3s;
44}
45
46nav a:hover {
47 opacity: 0.8;
48}
49
50main {
51 max-width: 1200px;
52 margin: 2rem auto;
53 padding: 0 2rem;
54}
55
56.hero {
57 background: white;
58 padding: 3rem;
59 border-radius: 8px;
60 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
61 text-align: center;
62 margin-bottom: 2rem;
63}
64
65.hero h2 {
66 font-size: 2.5rem;
67 margin-bottom: 1rem;
68 color: #2c3e50;
69}
70
71.hero p {
72 font-size: 1.2rem;
73 color: #666;
74}
75
76.features {
77 display: grid;
78 grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
79 gap: 2rem;
80 margin: 2rem 0;
81}
82
83.feature {
84 background: white;
85 padding: 2rem;
86 border-radius: 8px;
87 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
88}
89
90.feature h3 {
91 color: #2c3e50;
92 margin-bottom: 0.5rem;
93}
94
95.feature p {
96 color: #666;
97}
98
99.about {
100 background: white;
101 padding: 3rem;
102 border-radius: 8px;
103 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
104}
105
106.about h2 {
107 color: #2c3e50;
108 margin-bottom: 1rem;
109}
110
111.about h3 {
112 color: #34495e;
113 margin-top: 2rem;
114 margin-bottom: 0.5rem;
115}
116
117.about ul {
118 margin-left: 2rem;
119 margin-bottom: 1rem;
120}
121
122.about pre {
123 background: #f5f5f5;
124 padding: 1rem;
125 border-radius: 4px;
126 overflow-x: auto;
127 margin-top: 1rem;
128}
129
130.about code {
131 font-family: 'Courier New', monospace;
132 font-size: 0.9rem;
133}
134
135footer {
136 background: #2c3e50;
137 color: white;
138 text-align: center;
139 padding: 2rem;
140 margin-top: 4rem;
141}
142
143@media (max-width: 768px) {
144 nav {
145 flex-direction: column;
146 gap: 1rem;
147 }
148
149 .hero h2 {
150 font-size: 2rem;
151 }
152
153 .features {
154 grid-template-columns: 1fr;
155 }
156}