diff --git a/docs/deployment-plan.md b/docs/deployment-plan.md new file mode 100644 index 0000000..ae2d277 --- /dev/null +++ b/docs/deployment-plan.md @@ -0,0 +1,342 @@ +# Deployment Plan: Docker + VPS + Domain + +**Goal:** Get the app running publicly so anyone can connect with `ssh yourdomain.com` + +--- + +## Overview + +Reference uses AWS ECS + SST (overkill). We use: + +- **Hetzner VPS** (~€4/month) — Linux server with a real public IP +- **Docker Compose** — runs all services, replaces `dev.sh`/Procfile +- **Caddy** — reverse proxy for the API, handles HTTPS automatically (free Let's Encrypt) +- **Cloudflare** — DNS (free tier, fast propagation) +- **Your domain** — e.g. `yourcoffee.shop` + +Port layout on the VPS: +- `:22` → SSH TUI server (users connect here) +- `:443` → API (Caddy terminates TLS, proxies to API container on :8080) +- `:80` → Caddy (redirects to HTTPS) + +--- + +## Step 1: Prerequisites + +### 1a. Buy a domain +- Recommended: Cloudflare Registrar (cheapest, DNS included, no upsells) +- Or: Namecheap, then point nameservers to Cloudflare + +### 1b. Get a VPS +- **Hetzner Cloud** → Create account → New Server + - Location: closest to you (Falkenstein/Helsinki for EU) + - OS: Ubuntu 24.04 + - Type: CX22 (2 vCPU, 4GB RAM, €3.79/month) — plenty for this + - Add your SSH public key during setup so you can SSH in +- Note the public IP address + +### 1c. DNS (Cloudflare) +Add these A records pointing to your VPS IP: + +| Type | Name | Value | +|------|------|-------| +| A | `@` | `` | +| A | `www` | `` | +| A | `api` | `` | + +Set proxy status to **DNS only** (grey cloud) — not proxied. Cloudflare proxying breaks raw TCP (SSH). + +--- + +## Step 2: Files to Create + +### `Dockerfile.ssh` + +Directly based on reference's `packages/go/Dockerfile`. Two-stage build: compile in Go Alpine, run in minimal Alpine. + +The reference uses `CGO_ENABLED=0` but our SQLite driver needs CGO. Fix: switch to the pure-Go SQLite driver (`modernc.org/sqlite`) so we can keep `CGO_ENABLED=0` and get tiny images. + +```dockerfile +FROM golang:alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o ssh ./main.go + +FROM alpine:3.21 +WORKDIR /root/ +COPY --from=builder /app/ssh . +CMD TERM=xterm-256color ./ssh +``` + +### `Dockerfile.api` + +```dockerfile +FROM golang:alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o api ./api/main.go + +FROM alpine:3.21 +WORKDIR /root/ +COPY --from=builder /app/api . +CMD ["./api"] +``` + +### `docker-compose.prod.yml` + +```yaml +services: + api: + build: + context: . + dockerfile: Dockerfile.api + restart: unless-stopped + env_file: .env.prod + volumes: + - ./data:/data + expose: + - "8080" + + ssh: + build: + context: . + dockerfile: Dockerfile.ssh + restart: unless-stopped + env_file: .env.prod + ports: + - "22:22" + volumes: + - ./.ssh:/root/.ssh + depends_on: + - api + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: +``` + +### `Caddyfile` + +``` +api.yourdomain.com { + reverse_proxy api:8080 +} +``` + +Caddy automatically gets a Let's Encrypt cert for the domain. No manual SSL setup. + +### `.env.prod` + +``` +# API +API_PORT=8080 +DATABASE_URL=/data/terminalshop.db +JWT_SECRET= +AUTH_FINGERPRINT_KEY= +ENVIRONMENT=production +APP_URL=https://api.yourdomain.com + +# Stripe (live keys in production) +STRIPE_SECRET_KEY=sk_live_... +STRIPE_PUBLIC_KEY=pk_live_... +STRIPE_WEBHOOK_SECRET= + +# Shipping +SHIPPO_API_KEY=... +BRING_API_UID=... +BRING_API_KEY=... + +# SSH server +API_URL=http://api:8080 +``` + +Note: `API_URL` uses `api` (the Docker Compose service name) not `localhost` — containers talk to each other by service name. + +### `.dockerignore` + +``` +.git +.env +.env.* +!.env.example +terminal-shop-source/ +*.db +.ssh/ +docs/ +``` + +--- + +## Step 3: Code Changes + +### 3a. SSH server host binding + +`main.go` currently listens on `localhost`. In Docker it must listen on `0.0.0.0` to accept connections from outside the container. + +```go +// Change: +host = "localhost" +port = "23456" + +// To (read from env): +host = "0.0.0.0" +port = os.Getenv("SSH_PORT") // default "22" in prod, "23456" in dev +``` + +Or just hardcode `0.0.0.0` and keep port as env var. + +### 3b. SSH host key path + +Currently hardcoded to `.ssh/term_info_ed25519`. In Docker this needs to be an absolute path or a path relative to the working directory that gets volume-mounted. The volume mount in `docker-compose.prod.yml` handles this — generate the key once on the server and it persists. + +Generate on the VPS before first run: +```bash +mkdir -p .ssh +ssh-keygen -t ed25519 -f .ssh/term_info_ed25519 -N "" +``` + +### 3c. Switch SQLite driver to pure Go + +In `pkg/database/db.go`, swap the import: + +```go +// Remove: +_ "github.com/mattn/go-sqlite3" // or however it's imported + +// Add: +_ "modernc.org/sqlite" +``` + +And update go.mod: +```bash +go get modernc.org/sqlite +go mod tidy +``` + +This lets us use `CGO_ENABLED=0` in both Dockerfiles, keeping images small and the build simple. + +--- + +## Step 4: VPS Setup + +SSH into your new server: + +```bash +ssh root@ +``` + +Install Docker: +```bash +curl -fsSL https://get.docker.com | sh +``` + +Clone your repo: +```bash +git clone https://github.com/yourusername/terminalShop.git +cd terminalShop +``` + +Create data directory and generate SSH host key: +```bash +mkdir -p data .ssh +ssh-keygen -t ed25519 -f .ssh/term_info_ed25519 -N "" +``` + +Create `.env.prod` with your production secrets (copy template above, fill in real values). + +Open firewall: +```bash +ufw allow 22/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw enable +``` + +**Important:** Port 22 is now used by your app. Change admin SSH to a different port first (e.g. 2222) so you don't lock yourself out: + +```bash +# In /etc/ssh/sshd_config, change: +Port 2222 + +systemctl restart sshd +# Open new terminal, verify: ssh root@ -p 2222 +# Then: ufw allow 2222/tcp +``` + +--- + +## Step 5: First Deploy + +On the VPS: +```bash +docker compose -f docker-compose.prod.yml up -d --build +``` + +Check logs: +```bash +docker compose -f docker-compose.prod.yml logs -f +``` + +Test: +```bash +ssh yourdomain.com +``` + +--- + +## Step 6: Stripe Webhook + +In Stripe Dashboard → Developers → Webhooks: +- Add endpoint: `https://api.yourdomain.com/api/v1/webhooks/stripe` +- Copy the signing secret into `.env.prod` as `STRIPE_WEBHOOK_SECRET` +- No more ngrok needed in production + +--- + +## Step 7: Ongoing Deploys + +When you push changes: +```bash +# On VPS: +git pull +docker compose -f docker-compose.prod.yml up -d --build +``` + +Or automate with a GitHub Action that SSHes into the VPS and runs this on every push to main. + +--- + +## Dev vs Prod Summary + +| | Dev | Prod | +|--|-----|------| +| Process manager | Goreman + Procfile | Docker Compose | +| SSH port | 23456 | 22 | +| API URL | localhost:8080 | api.yourdomain.com (HTTPS) | +| Database | `terminalshop.db` (local) | `/data/terminalshop.db` (volume) | +| Stripe | ngrok + test keys | real domain + live keys | +| Webhooks | stripe CLI listener | Stripe dashboard endpoint | + +--- + +## TODO After Deployment + +- [ ] Switch from SQLite to PostgreSQL (add `postgres` service to compose, update connection string) +- [ ] Set up GitHub Actions for automatic deploy on push to main +- [ ] Add health check endpoint to Docker Compose so containers restart if app hangs +- [ ] Structured logging (write to file + stdout in prod) diff --git a/main.go b/main.go index d51cbb3..b6e346a 100644 --- a/main.go +++ b/main.go @@ -22,10 +22,23 @@ import ( ) const ( - host = "localhost" port = "23456" ) +func listenHost() string { + if h := os.Getenv("SSH_HOST"); h != "" { + return h + } + return "localhost" +} + +func listenPort() string { + if p := os.Getenv("SSH_PORT"); p != "" { + return p + } + return port +} + var ( apiURL string authFingerprintKey string @@ -63,8 +76,10 @@ func main() { log.Printf("TUI will connect to API at: %s", apiURL) // Create SSH server + host := listenHost() + p := listenPort() s, err := wish.NewServer( - wish.WithAddress(fmt.Sprintf("%s:%s", host, port)), + wish.WithAddress(fmt.Sprintf("%s:%s", host, p)), wish.WithHostKeyPath(".ssh/term_info_ed25519"), wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { // Allow all public keys (we handle auth in the tea handler) @@ -82,8 +97,8 @@ func main() { done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - log.Printf("Starting SSH server on %s:%s", host, port) - log.Printf("Connect with: ssh %s -p %s", host, port) + log.Printf("Starting SSH server on %s:%s", host, p) + log.Printf("Connect with: ssh %s -p %s", host, p) go func() { if err = s.ListenAndServe(); err != nil {