This commit is contained in:
2026-04-09 19:06:33 +02:00
parent 7c00202929
commit ef5c4ef554
2 changed files with 361 additions and 4 deletions

342
docs/deployment-plan.md Normal file
View File

@@ -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 | `@` | `<VPS IP>` |
| A | `www` | `<VPS IP>` |
| A | `api` | `<VPS IP>` |
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=<generate with: openssl rand -hex 32>
AUTH_FINGERPRINT_KEY=<generate with: openssl rand -hex 32>
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=<from stripe dashboard>
# 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@<VPS IP>
```
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@<VPS IP> -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)

23
main.go
View File

@@ -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 {