Ubuntu 24.04 on a VPS with a persistent data volume.
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$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
Mount a persistent data volume at $VOLUME_PATH and verify it survives reboots:
grep "$VOLUME_PATH" /etc/fstabCreate 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/aiPostgres 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:
systemctl stop ${PROJECT}-prod ${PROJECT}-staging ${PROJECT}-dev- Detach the volume from the old machine
- Attach to new machine, mount at
$VOLUME_PATH - Install Docker, recreate symlinks, restore systemd units — data already there
<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 $DOMAINapt 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 enableuseradd -r -M -d /opt/$PROJECT -s /bin/bash $DEPLOY_USERNAME
usermod -aG docker $DEPLOY_USERNAME
chown -R $DEPLOY_USERNAME:$DEPLOY_USERNAME $PROJECT_PATHdocker 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-credentialsUse a fine-grained PAT scoped to this repo, contents read-only.
Rotate by replacing .git-credentials — no server config changes needed.
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/prodCreate $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/dataopenssl 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/.envscripts/server/setup.sh generates and installs:
/etc/systemd/system/${PROJECT}-{prod,dev,deploy.path,deploy.service}/etc/nginx/conf.d/${PROJECT}-rate-limits.conf—limit_req_zonefor/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/defaultso the stock catch-all stops shadowing the project site
cd $PROJECT_PATH/prod
bash scripts/server/setup.shScript 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.
After step 7's bootstrap nginx is reloaded:
certbot --nginx -d $DOMAIN --non-interactive --agree-tos -m $ADMIN_EMAILThen re-run setup.sh to swap in the HTTPS config:
bash scripts/server/setup.shVerify auto-renew:
systemctl status certbot.timer
certbot renew --dry-runsystemctl start ${PROJECT}-prod
systemctl start ${PROJECT}-devCheck:
systemctl status ${PROJECT}-prod
docker compose --project-directory $PROJECT_PATH/prod logs -f appProd app binds
127.0.0.1:3000— host nginx proxies to127.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 indocker ps.
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.shTo intentionally reseed after a volume loss + recovery decision: delete the sentinel, then the next deploy (or manual run) reseeds.
cd $PROJECT_PATH/prod/webhook
sudo -u $DEPLOY_USERNAME docker compose up -dAdd webhook in GitHub repo → Settings → Webhooks:
- Payload URL:
https://$DOMAIN/hooks/deploy-prod - Content type:
application/json - Secret:
$WEBHOOK_SECRET - Events:
pushonly
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.
sudo -u $DEPLOY_USERNAME git clone \
https://github.com/Ecohackerfarm/power2plant.git \
$PROJECT_PATH/stagingCreate $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=prodchmod 600 $PROJECT_PATH/staging/.envSTAGING_DATA_SOURCE controls the one-time DB bootstrap:
prod— pipes a non-userpg_dumpfrom the live prod DB into staging (plant data, relationships, research requests, etc.); auth, garden, bed, and planting tables excludedseed(default) — restoresdb/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.
Export STAGING_DOMAIN and STAGING_WEBHOOK_SECRET (see Variables section), then re-run:
cd $PROJECT_PATH/prod
bash scripts/server/setup.shThis 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.shcertbot --nginx -d $STAGING_DOMAIN --non-interactive --agree-tos -m $ADMIN_EMAILRe-run setup.sh to swap the bootstrap config to full HTTPS:
bash scripts/server/setup.shsystemctl start ${PROJECT}-stagingSeed bootstrap runs automatically on the first deploy triggered by a release/* push.
GitHub repo → Settings → Webhooks → Add webhook:
- Payload URL:
https://$STAGING_DOMAIN/hooks/deploy-staging - Content type:
application/json - Secret:
$STAGING_WEBHOOK_SECRET - Events:
pushonly
Pushes to release/* trigger staging; pushes to main trigger prod. Each hook validates its own HMAC-256 secret independently.
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 instructionsMinimum 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: trueCreate the external network first:
docker network create ${PROJECT}-devAgent 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| 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.
cd $PROJECT_PATH/prod
sudo -u $DEPLOY_USERNAME git pull origin main
sudo -u $DEPLOY_USERNAME docker compose up -d --buildcd $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 --buildsudo -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).sqlsudo -u $DEPLOY_USERNAME docker compose \
--project-directory $PROJECT_PATH/prod logs -f appcertbot renew --dry-runcd $PROJECT_PATH/prod && git pull origin main
bash scripts/server/setup.sh