# 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)