devops: deployment setup and docs
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m0s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m0s
This commit is contained in:
parent
c28c3a22e0
commit
6e00b7387e
6 changed files with 491 additions and 3 deletions
40
.forgejo/workflows/deploy.yaml
Normal file
40
.forgejo/workflows/deploy.yaml
Normal file
|
|
@ -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"
|
||||
12
Taskfile.yml
12
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
27
docs/budgit.service
Normal file
27
docs/budgit.service
Normal file
|
|
@ -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
|
||||
99
docs/database-setup.md
Normal file
99
docs/database-setup.md
Normal file
|
|
@ -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.
|
||||
272
docs/first-time-deployment.md
Normal file
272
docs/first-time-deployment.md
Normal file
|
|
@ -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=<run: openssl rand -base64 32>
|
||||
|
||||
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue