diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml new file mode 100644 index 0000000..e9e2bca --- /dev/null +++ b/.forgejo/workflows/deploy.yaml @@ -0,0 +1,40 @@ +name: Deploy +on: + push: + tags: + - 'v*' +jobs: + build-and-deploy: + runs-on: ubuntu-full-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Build + run: task build + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts + - name: Deploy binary + run: | + SSH_CMD="ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" + ${SSH_CMD} "cp ${{ secrets.DEPLOY_PATH }}/budgit ${{ secrets.DEPLOY_PATH }}/budgit.prev 2>/dev/null || true" + scp -i ~/.ssh/deploy_key ./dist/budgit ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.DEPLOY_PATH }}/budgit.new + ${SSH_CMD} "mv ${{ secrets.DEPLOY_PATH }}/budgit.new ${{ secrets.DEPLOY_PATH }}/budgit && sudo systemctl restart budgit" + - name: Verify deployment + run: | + sleep 5 + for i in 1 2 3 4 5; do + status=$(curl -s -o /dev/null -w "%{http_code}" "${{ secrets.APP_URL }}/healthz" || true) + [ "$status" = "200" ] && echo "Health check passed" && exit 0 + echo "Attempt $i: got $status, retrying in 3s..." + sleep 3 + done + echo "Health check failed" && exit 1 + - name: Rollback on failure + if: failure() + run: | + SSH_CMD="ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" + ${SSH_CMD} "[ -f ${{ secrets.DEPLOY_PATH }}/budgit.prev ] && mv ${{ secrets.DEPLOY_PATH }}/budgit.prev ${{ secrets.DEPLOY_PATH }}/budgit && sudo systemctl restart budgit" diff --git a/Taskfile.yml b/Taskfile.yml index f43599b..ade69be 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -25,3 +25,15 @@ tasks: cmds: - echo "Starting app..." - task --parallel tailwind-watch templ + + # Production build + build: + desc: Build production binary + vars: + VERSION: + sh: git describe --tags --always + cmds: + - tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --minify + - go tool templ generate + - mkdir -p ./dist + - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version={{.VERSION}}" -o ./dist/budgit ./cmd/server/main.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 0b05d4d..accaf6a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,15 +1,23 @@ package main import ( + "context" "fmt" "log/slog" "net/http" + "os" + "os/signal" + "syscall" + "time" "git.juancwu.dev/juancwu/budgit/internal/app" "git.juancwu.dev/juancwu/budgit/internal/config" "git.juancwu.dev/juancwu/budgit/internal/routes" ) +// version is set at build time via -ldflags. +var version = "dev" + func main() { cfg := config.Load() @@ -26,10 +34,40 @@ func main() { }() handler := routes.SetupRoutes(a) - slog.Info("server starting", "host", cfg.Host, "port", cfg.Port, "env", cfg.AppEnv, "url", fmt.Sprintf("http://%s:%s", cfg.Host, cfg.Port)) - err = http.ListenAndServe(":"+cfg.Port, handler) - if err != nil { + // Health check bypasses all middleware + finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/healthz" { + if err := a.DB.Ping(); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("db: unreachable")) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok" + " - version: " + version)) + return + } + handler.ServeHTTP(w, r) + }) + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: finalHandler, + } + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + <-sigCh + slog.Info("shutting down gracefully") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + srv.Shutdown(ctx) + }() + + slog.Info("server starting", "version", version, "host", cfg.Host, "port", cfg.Port, "env", cfg.AppEnv, "url", fmt.Sprintf("http://%s:%s", cfg.Host, cfg.Port)) + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server failed", "error", err) panic(err) } diff --git a/docs/budgit.service b/docs/budgit.service new file mode 100644 index 0000000..d4cbbaf --- /dev/null +++ b/docs/budgit.service @@ -0,0 +1,27 @@ +[Unit] +Description=Budgit web application +After=network.target postgresql.service +Requires=postgresql.service + +[Service] +Type=simple +User=budgit +Group=budgit +WorkingDirectory=/opt/budgit +ExecStart=/opt/budgit/budgit +EnvironmentFile=/opt/budgit/.env +Restart=on-failure +RestartSec=5 + +# Graceful shutdown (matches the 10s timeout in main.go) +TimeoutStopSec=15 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/budgit +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/database-setup.md b/docs/database-setup.md new file mode 100644 index 0000000..4a0640c --- /dev/null +++ b/docs/database-setup.md @@ -0,0 +1,99 @@ +# Database Setup + +This guide covers setting up PostgreSQL for Budgit in production. The application connects as user `budgit-admin` via unix socket. + +## Prerequisites + +- PostgreSQL 15+ installed and running +- Root or sudo access on the server + +## 1. Create the database user + +```bash +sudo -u postgres createuser --login --no-superuser --no-createdb --no-createrole budgit-admin +``` + +Set a password (only needed if connecting over TCP rather than unix socket): + +```bash +sudo -u postgres psql -c "ALTER USER \"budgit-admin\" PASSWORD 'your-secure-password';" +``` + +## 2. Create the database + +```bash +sudo -u postgres createdb --owner=budgit-admin budgit +``` + +## 3. Configure `pg_hba.conf` + +The app connects via unix socket from the `budgit` system user. Add the following line to `pg_hba.conf` (before any generic `local` rules): + +``` +# TYPE DATABASE USER METHOD +local budgit budgit-admin peer map=budgit +``` + +Then add the mapping in `pg_ident.conf` so the `budgit` system user can authenticate as `budgit-admin`: + +``` +# MAPNAME SYSTEM-USERNAME PG-USERNAME +budgit budgit budgit-admin +``` + +Reload PostgreSQL to apply: + +```bash +sudo systemctl reload postgresql +``` + +## 4. Verify the connection + +Via unix socket (peer auth): + +```bash +sudo -u budgit psql -U budgit-admin -d budgit -c "SELECT 1;" +``` + +Via TCP (password auth): + +```bash +psql -h 127.0.0.1 -U budgit-admin -d budgit -c "SELECT 1;" +``` + +## 5. Application configuration + +Set these variables in `/opt/budgit/.env`: + +```bash +DB_DRIVER=pgx +DB_CONNECTION=postgres://budgit-admin@/budgit?host=/run/postgresql&sslmode=disable +``` + +The connection string uses: +- `budgit-admin` as the PostgreSQL user +- `/budgit` as the database name +- `host=/run/postgresql` to connect via unix socket (adjust the path if your distro uses a different socket directory, e.g. `/var/run/postgresql`) +- `sslmode=disable` since traffic stays on localhost + +## 6. Migrations + +Migrations run automatically on application startup via Goose (embedded in the binary from `internal/db/migrations/`). No manual migration step is needed. + +To verify migrations ran: + +```bash +sudo -u budgit psql -U budgit-admin -d budgit -c "\dt" +``` + +You should see tables: `users`, `tokens`, `profiles`, `files`, `spaces`, `space_members`, `shopping_lists`, `list_items`, `tags`, `expenses`, `expense_tags`, `space_invitations`, and `goose_db_version`. + +## Troubleshooting + +**"peer authentication failed"** -- The system user running the app doesn't match the `pg_hba.conf` peer mapping. Ensure the app runs as the `budgit` system user and the `pg_ident.conf` mapping is in place. + +**"connection refused"** -- PostgreSQL isn't listening on the expected socket path. Check with `pg_lscluster` or `ss -xln | grep postgres` and adjust the `host=` parameter in `DB_CONNECTION`. + +**"role budgit-admin does not exist"** -- The user wasn't created. Re-run step 1. + +**"database budgit does not exist"** -- The database wasn't created. Re-run step 2. diff --git a/docs/first-time-deployment.md b/docs/first-time-deployment.md new file mode 100644 index 0000000..a8fe011 --- /dev/null +++ b/docs/first-time-deployment.md @@ -0,0 +1,272 @@ +# First-Time Deployment + +This guide walks through every manual step needed on a fresh server before the CI/CD workflow can auto-deploy. +After completing this once, all future deploys happen automatically when you push a `v*` tag. + +## Prerequisites + +- A Linux server (Debian/Ubuntu assumed, adjust package commands for other distros) +- Root or sudo access +- A domain name pointed at the server's IP +- PostgreSQL 15+ installed +- Caddy installed + +## Step 1: Create the system user + +Create a dedicated `budgit` user with no login shell and no home directory: + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin budgit +``` + +Create a dedicated deploy user that CI will SSH into: + +```bash +sudo useradd --create-home --shell /bin/bash deploy +``` + +Generate an SSH key pair (on your local machine or CI): + +```bash +ssh-keygen -t ed25519 -f deploy_key -N "" -C "budgit-ci-deploy" +``` + +Install the public key on the server: + +```bash +sudo mkdir -p /home/deploy/.ssh +sudo cp deploy_key.pub /home/deploy/.ssh/authorized_keys +sudo chown -R deploy:deploy /home/deploy/.ssh +sudo chmod 700 /home/deploy/.ssh +sudo chmod 600 /home/deploy/.ssh/authorized_keys +``` + +Grant the deploy user the specific sudo permissions it needs (no password): + +```bash +sudo tee /etc/sudoers.d/budgit-deploy > /dev/null << 'EOF' +deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart budgit +EOF +sudo chmod 440 /etc/sudoers.d/budgit-deploy +``` + +The deploy user also needs write access to the deploy path: + +```bash +sudo setfacl -m u:deploy:rwx /opt/budgit +``` + +Or alternatively, add `deploy` to the `budgit` group and ensure group write: + +```bash +sudo usermod -aG budgit deploy +sudo chmod 770 /opt/budgit +``` + +## Step 2: Create the application directory + +```bash +sudo mkdir -p /opt/budgit +sudo chown budgit:budgit /opt/budgit +sudo chmod 750 /opt/budgit +``` + +## Step 3: Set up PostgreSQL + +Follow [docs/database-setup.md](database-setup.md) in full. By the end you should have: + +- A `budgit-admin` PostgreSQL role +- A `budgit` database owned by `budgit-admin` +- `pg_hba.conf` peer auth with an ident map so the `budgit` system user authenticates as `budgit-admin` + +Verify it works: + +```bash +sudo -u budgit psql -U budgit-admin -d budgit -c "SELECT 1;" +``` + +## Step 4: Create the environment file + +```bash +sudo -u budgit tee /opt/budgit/.env > /dev/null << 'EOF' +APP_ENV=production +APP_URL=https://budgit.now +HOST=127.0.0.1 +PORT=9000 + +DB_DRIVER=pgx +DB_CONNECTION=postgres://budgit-admin@/budgit?host=/run/postgresql&sslmode=disable + +JWT_SECRET= + +MAILER_SMTP_HOST= +MAILER_SMTP_PORT=587 +MAILER_IMAP_HOST= +MAILER_IMAP_PORT=993 +MAILER_USERNAME= +MAILER_PASSWORD= +MAILER_EMAIL_FROM= +SUPPORT_EMAIL= +EOF +``` + +Generate and fill in the `JWT_SECRET`: + +```bash +openssl rand -base64 32 +``` + +Fill in the mailer variables if email is configured. Lock down permissions: + +```bash +sudo chmod 600 /opt/budgit/.env +``` + +## Step 5: Do the initial binary deploy + +Build locally (or on any machine with Go + Tailwind + Task installed): + +```bash +task build +``` + +Copy the binary to the server: + +```bash +scp ./dist/budgit your-user@your-server:/tmp/budgit +ssh your-user@your-server "sudo mv /tmp/budgit /opt/budgit/budgit && sudo chown budgit:budgit /opt/budgit/budgit && sudo chmod 755 /opt/budgit/budgit" +``` + +## Step 6: Install the systemd service + +Copy the unit file from this repo: + +```bash +scp docs/budgit.service your-user@your-server:/tmp/budgit.service +ssh your-user@your-server "sudo mv /tmp/budgit.service /etc/systemd/system/budgit.service" +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable budgit +sudo systemctl start budgit +``` + +Check it's running: + +```bash +sudo systemctl status budgit +curl http://127.0.0.1:9000/healthz +``` + +You should see `ok`. + +## Step 7: Configure Caddy + +Replace the existing `budgit.now` site block in your Caddyfile (typically `/etc/caddy/Caddyfile`). + +Before (static file server): + +```caddyfile +budgit.now, www.budgit.now, mta-sts.budgit.now, autodiscover.budgit.now { + import common_headers + import budgit_now_ssl + root * /var/www/budgit.now + file_server +} +``` + +After (split app from other subdomains): + +```caddyfile +budgit.now, www.budgit.now { + import common_headers + import budgit_now_ssl + encode gzip zstd + + handle /.well-known/* { + root * /var/www/budgit.now + file_server + } + + handle { + reverse_proxy 127.0.0.1:9000 { + health_uri /healthz + health_interval 10s + health_timeout 3s + } + } +} + +mta-sts.budgit.now, autodiscover.budgit.now { + import common_headers + import budgit_now_ssl + root * /var/www/budgit.now + file_server +} +``` + +Reload Caddy: + +```bash +sudo systemctl reload caddy +``` + +Verify the public endpoint: + +```bash +curl https://budgit.now/healthz +``` + +## Step 8: Configure Forgejo secrets + +In your Forgejo repository, go to **Settings > Secrets** and add: + +| Secret | Value | +|---|---| +| `SSH_KEY` | Contents of `deploy_key` (the private key) | +| `SSH_USER` | `deploy` | +| `SSH_HOST` | Your server's IP or hostname | +| `DEPLOY_PATH` | `/opt/budgit` | +| `APP_URL` | `https://budgit.now` | + +## Step 9: Verify auto-deploy + +Tag and push to trigger the workflow: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +Watch the workflow in Forgejo's Actions tab. It should: + +1. Build the binary with the version baked in +2. SCP it to the server +3. Restart the service +4. Pass the health check + +Confirm the version is running: + +```bash +journalctl -u budgit --no-pager -n 5 +``` + +You should see a log line like `server starting version=v0.1.0`. + +## Summary + +After completing these steps, the deployment flow is: + +``` +git tag v1.2.3 && git push origin v1.2.3 + -> Forgejo workflow triggers + -> Builds binary with version embedded + -> SCPs to server, restarts systemd + -> Health check verifies + -> Auto-rollback on failure +``` + +No further manual steps are needed for subsequent deploys.