Skip to content

Latest commit

 

History

History
467 lines (351 loc) · 13 KB

File metadata and controls

467 lines (351 loc) · 13 KB

Server Setup — power2plant

Ubuntu 24.04 on a VPS with a persistent data volume.

Variables

Export these before running any commands or scripts:

export VOLUME_PATH=/mnt/data   # path where your persistent volume is mounted
export DEPLOY_USERNAME=deploy
export PROJECT=power2plant
export DOMAIN=power2plant.ecohackerfarm.org
export ADMIN_EMAIL=admin@ecohackerfarm.com
export GITHUB_USER=JustTB
export GITHUB_PAT=<fine-grained-PAT>        # never commit a real value here
export WEBHOOK_SECRET=$(openssl rand -hex 32)   # generate once; store somewhere safe

# Staging (optional — set to enable staging environment)
export STAGING_DOMAIN=staging.power2plant.ecohackerfarm.org
export STAGING_WEBHOOK_SECRET=$(openssl rand -hex 32)
export STAGING_APP_PORT=3001  # default; override if 3001 is taken

# Derived — don't change:
export PROJECT_PATH=$VOLUME_PATH/$PROJECT
export AI_PATH=$VOLUME_PATH/ai

Architecture

$VOLUME_PATH/
├── $PROJECT/              ← this project
│   ├── prod/              (production clone — app + db)
│   ├── staging/           (staging clone — deploys on release/*)
│   ├── dev/               (dev clone — agent works here)
│   └── (webhook/ lives inside prod clone)
└── ai/                    ← orchestration agent (separate, multi-project)

Symlinks:  /opt/$PROJECT → $PROJECT_PATH
           /opt/ai       → $AI_PATH

Host:  nginx (www-data) — TLS termination → prod:3000, staging:3001
       certbot (root)   — Let's Encrypt auto-renew
       $DEPLOY_USERNAME — system user, owns project dirs, runs compose

Deploy trigger flows:

push → main         → webhook deploy-prod    → /run/p2p/deploy.trigger
                    → systemd path unit      → deploy.service → scripts/server/deploy.sh

push → release/*    → webhook deploy-staging → /run/p2p/staging-deploy.branch
                                             → /run/p2p/staging-deploy.trigger
                    → systemd path unit      → staging-deploy.service → scripts/server/staging-deploy.sh

1. Persistent volume

Mount a persistent data volume at $VOLUME_PATH and verify it survives reboots:

grep "$VOLUME_PATH" /etc/fstab

Create layout and symlinks:

mkdir -p $PROJECT_PATH/{prod/data,staging/data,dev/data}
mkdir -p $AI_PATH

ln -s $PROJECT_PATH /opt/$PROJECT
ln -s $AI_PATH /opt/ai

Postgres data lands on the volume via VOLUME_DATA_DIR in each stack's .env. Docker images stay on root disk — disposable, re-pull on a new machine.

To migrate to a new machine:

  1. systemctl stop ${PROJECT}-prod ${PROJECT}-staging ${PROJECT}-dev
  2. Detach the volume from the old machine
  3. Attach to new machine, mount at $VOLUME_PATH
  4. Install Docker, recreate symlinks, restore systemd units — data already there

2. DNS

<your-domain>    A       <your-ipv4-address>
<your-domain>    AAAA    <your-ipv6-address>   # optional

IPv4 is required for GitHub webhook delivery (GitHub doesn't support IPv6). Certbot, nginx, and browser traffic work on either.

Verify addresses:

ip -4 addr show   # confirm IPv4
ip -6 addr show   # confirm IPv6 (if applicable)

Verify propagation before requesting certs:

dig AAAA $DOMAIN
dig A $DOMAIN

3. Host prerequisites

apt update && apt upgrade -y

# nginx + certbot (Docker already installed)
apt install -y nginx certbot python3-certbot-nginx

# Firewall
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
# 2222 (dev SSH) stays internal — agent reaches it via Docker network only
ufw --force enable

4. System user

useradd -r -M -d /opt/$PROJECT -s /bin/bash $DEPLOY_USERNAME
usermod -aG docker $DEPLOY_USERNAME
chown -R $DEPLOY_USERNAME:$DEPLOY_USERNAME $PROJECT_PATH

docker group is effectively root-equivalent for Docker ops — the gain is host system isolation and auditability, not a hard security boundary.

Git credentials via HTTPS PAT — stored in the deploy user's credential store, not embedded in remote URLs (keeps git remote -v and process list clean):

sudo -u $DEPLOY_USERNAME git config --global credential.helper store
echo "https://${GITHUB_USER}:${GITHUB_PAT}@github.com" \
  > $PROJECT_PATH/.git-credentials
chmod 600 $PROJECT_PATH/.git-credentials
chown $DEPLOY_USERNAME:$DEPLOY_USERNAME $PROJECT_PATH/.git-credentials

Use a fine-grained PAT scoped to this repo, contents read-only. Rotate by replacing .git-credentials — no server config changes needed.


5. Clone the repo

sudo -u $DEPLOY_USERNAME git clone \
  https://github.com/Ecohackerfarm/power2plant.git \
  $PROJECT_PATH/dev

sudo -u $DEPLOY_USERNAME git clone \
  https://github.com/Ecohackerfarm/power2plant.git \
  $PROJECT_PATH/prod

6. Production .env

Create $PROJECT_PATH/prod/.env:

DATABASE_URL=postgresql://power2plant:<strong-db-password>@db:5432/power2plant
POSTGRES_PASSWORD=<same-strong-db-password>
BETTER_AUTH_SECRET=<min-32-char-random>
BETTER_AUTH_URL=https://power2plant.ecohackerfarm.org
NEXT_PUBLIC_APP_URL=https://power2plant.ecohackerfarm.org
VOLUME_DATA_DIR=$VOLUME_PATH/power2plant/prod/data
openssl rand -base64 48   # → BETTER_AUTH_SECRET
openssl rand -base64 24   # → DB password (use in both DATABASE_URL and POSTGRES_PASSWORD)

chmod 600 $PROJECT_PATH/prod/.env

7. Install services and nginx (bootstrap)

scripts/server/setup.sh generates and installs:

  • /etc/systemd/system/${PROJECT}-{prod,dev,deploy.path,deploy.service}
  • /etc/nginx/conf.d/${PROJECT}-rate-limits.conflimit_req_zone for /share/* (60 req/min per IP)
  • /etc/nginx/sites-available/${PROJECT} (symlinked to enabled) — includes rate-limited /share/ location
  • $PROJECT_PATH/prod/webhook/hooks.json (from template, secret substituted; only on first run)
  • Removes /etc/nginx/sites-enabled/default so the stock catch-all stops shadowing the project site
cd $PROJECT_PATH/prod
bash scripts/server/setup.sh

Script validates all variables are set and errors with missing names if not.

The script is cert-aware: on the first run no Let's Encrypt cert exists yet, so it installs an HTTP-only bootstrap nginx config (port 80, proxies / to the app) that passes nginx -t. After step 8 obtains the cert, re-run the script — it detects the cert and installs the full HTTPS config with :80 → :443 redirect.


8. TLS cert

After step 7's bootstrap nginx is reloaded:

certbot --nginx -d $DOMAIN --non-interactive --agree-tos -m $ADMIN_EMAIL

Then re-run setup.sh to swap in the HTTPS config:

bash scripts/server/setup.sh

Verify auto-renew:

systemctl status certbot.timer
certbot renew --dry-run

9. Start production

systemctl start ${PROJECT}-prod
systemctl start ${PROJECT}-dev

Check:

systemctl status ${PROJECT}-prod
docker compose --project-directory $PROJECT_PATH/prod logs -f app

Prod app binds 127.0.0.1:3000 — host nginx proxies to 127.0.0.1:3000. /share/* traffic is rate-limited to 60 req/min per IP (burst 10) at the nginx layer. If the app isn't reachable via nginx, verify the port binding in docker ps.

Seed plant data (first install only)

The app starts with an empty DB. scripts/server/seed-bootstrap.sh loads db/seed.sql (committed plant data) the first time, then writes a sentinel to $VOLUME_DATA_DIR/seeded.marker so it never runs again. deploy.sh invokes it on every deploy and the sentinel turns subsequent calls into a no-op — production data is never clobbered.

Run it once manually after first start, since the first deploy hasn't happened yet:

DEPLOY_USERNAME=$DEPLOY_USERNAME PROJECT_PATH=$PROJECT_PATH/prod \
  bash $PROJECT_PATH/prod/scripts/server/seed-bootstrap.sh

To intentionally reseed after a volume loss + recovery decision: delete the sentinel, then the next deploy (or manual run) reseeds.


10. Start webhook

cd $PROJECT_PATH/prod/webhook
sudo -u $DEPLOY_USERNAME docker compose up -d

Add webhook in GitHub repo → Settings → Webhooks:

  • Payload URL: https://$DOMAIN/hooks/deploy-prod
  • Content type: application/json
  • Secret: $WEBHOOK_SECRET
  • Events: push only

11. Staging environment

Staging runs on the same machine as prod, isolated in separate containers (ports 3001/5433). It deploys automatically on every push to a release/* branch.

Clone

sudo -u $DEPLOY_USERNAME git clone \
  https://github.com/Ecohackerfarm/power2plant.git \
  $PROJECT_PATH/staging

Staging .env

Create $PROJECT_PATH/staging/.env:

DATABASE_URL=postgresql://power2plant:<staging-db-password>@db:5432/power2plant
POSTGRES_PASSWORD=<staging-db-password>
BETTER_AUTH_SECRET=<min-32-char-random>
BETTER_AUTH_URL=https://staging.power2plant.ecohackerfarm.org
NEXT_PUBLIC_APP_URL=https://staging.power2plant.ecohackerfarm.org
VOLUME_DATA_DIR=$VOLUME_PATH/power2plant/staging/data
APP_PORT=3001
DB_PORT=5433
STAGING_DATA_SOURCE=prod
chmod 600 $PROJECT_PATH/staging/.env

STAGING_DATA_SOURCE controls the one-time DB bootstrap:

  • prod — pipes a non-user pg_dump from the live prod DB into staging (plant data, relationships, research requests, etc.); auth, garden, bed, and planting tables excluded
  • seed (default) — restores db/seed.sql (the committed dataset used for fresh prod installs)

Bootstrap is sentinel-guarded ($VOLUME_DATA_DIR/seeded.marker). To force a reseed after switching the source: rm $PROJECT_PATH/staging/data/seeded.marker, then push to a release/* branch.

Run setup.sh with staging vars

Export STAGING_DOMAIN and STAGING_WEBHOOK_SECRET (see Variables section), then re-run:

cd $PROJECT_PATH/prod
bash scripts/server/setup.sh

This adds the staging systemd units, nginx vhost, and regenerates webhook/hooks.json with both secrets. If hooks.json already exists from a previous run, delete it first so it is regenerated:

rm $PROJECT_PATH/prod/webhook/hooks.json
bash scripts/server/setup.sh

TLS cert (staging)

certbot --nginx -d $STAGING_DOMAIN --non-interactive --agree-tos -m $ADMIN_EMAIL

Re-run setup.sh to swap the bootstrap config to full HTTPS:

bash scripts/server/setup.sh

Start staging

systemctl start ${PROJECT}-staging

Seed bootstrap runs automatically on the first deploy triggered by a release/* push.

Add GitHub webhook (staging)

GitHub repo → Settings → Webhooks → Add webhook:

  • Payload URL: https://$STAGING_DOMAIN/hooks/deploy-staging
  • Content type: application/json
  • Secret: $STAGING_WEBHOOK_SECRET
  • Events: push only

Pushes to release/* trigger staging; pushes to main trigger prod. Each hook validates its own HMAC-256 secret independently.


12. AI agent stack

Lives at $AI_PATH (/opt/ai) — separate from this project, multi-project capable.

git clone <your-ai-repo> $AI_PATH
# Build and configure per that repo's instructions

Minimum wiring to reach this project's dev stack:

volumes:
  - /opt/power2plant/dev:/app
  - ai_home:/root/.claude

networks:
  - power2plant-dev

environment:
  - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
volumes:
  ai_home:
networks:
  power2plant-dev:
    external: true

Create the external network first:

docker network create ${PROJECT}-dev

Agent SSHes to dev app container:

ssh -i /home/ai/.ssh/power2plant_dev -p 2222 -o StrictHostKeyChecking=no node@app-dev
# root@app-dev for orchestrator

13. Port map

Port Bound to Service
22 host host SSH
80 0.0.0.0:80,[::]:80 nginx → HTTPS redirect
443 [::]:443 nginx → prod app + staging + /hooks/
3000 127.0.0.1:3000 prod app (Docker, not direct)
3001 127.0.0.1:3001 staging app (Docker, not direct)
2222 Docker-internal dev app SSH (ai agent only)
9000 127.0.0.1:9000 webhook (proxied via nginx /hooks/)

Ports 3001 and 9000 blocked by ufw — only reachable through nginx.


14. Day-2 ops

Manual deploy (prod)

cd $PROJECT_PATH/prod
sudo -u $DEPLOY_USERNAME git pull origin main
sudo -u $DEPLOY_USERNAME docker compose up -d --build

Manual deploy (staging)

cd $PROJECT_PATH/staging
sudo -u $DEPLOY_USERNAME git fetch origin
sudo -u $DEPLOY_USERNAME git checkout -B release/v0.x.y origin/release/v0.x.y
sudo -u $DEPLOY_USERNAME docker compose up -d --build

DB backup

sudo -u $DEPLOY_USERNAME docker compose \
  --project-directory $PROJECT_PATH/prod \
  exec db pg_dump -U power2plant power2plant \
  > backup-$(date +%Y%m%d).sql
chmod 600 backup-$(date +%Y%m%d).sql

Logs

sudo -u $DEPLOY_USERNAME docker compose \
  --project-directory $PROJECT_PATH/prod logs -f app

Cert renewal test

certbot renew --dry-run

Re-run setup (after repo changes to scripts or nginx needs)

cd $PROJECT_PATH/prod && git pull origin main
bash scripts/server/setup.sh