diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..04328fc --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,60 @@ +name: Build & Push Images + +# Builds the frontend + backend Docker images and pushes them to the Forgejo +# container registry (forgejo.snsnetlabs.com/sam/...). Runs on every push to +# main, and on-demand via the "Run workflow" button (workflow_dispatch). +# +# Requirements (see deploy/README.md): +# - Forgejo Actions secret FORGEJO_REGISTRY_TOKEN: a package-scoped token for +# user `sam`. +# - The runner must allow Docker builds: container.docker_host = "automount" +# in the forgejo-runner config (mounts /var/run/docker.sock into the job). + +on: + push: + branches: [main] + workflow_dispatch: + +env: + REGISTRY: forgejo.snsnetlabs.com + OWNER: sam + +jobs: + build: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Docker CLI + run: | + apt-get update + apt-get install -y --no-install-recommends docker.io + docker version + + - name: Log in to Forgejo registry + run: | + echo "${{ secrets.FORGEJO_REGISTRY_TOKEN }}" \ + | docker login "$REGISTRY" -u "$OWNER" --password-stdin + + - name: Build & push frontend image + run: | + docker build \ + -t "$REGISTRY/$OWNER/archnest:${{ github.sha }}" \ + -t "$REGISTRY/$OWNER/archnest:latest" \ + -f Dockerfile . + docker push "$REGISTRY/$OWNER/archnest:${{ github.sha }}" + docker push "$REGISTRY/$OWNER/archnest:latest" + + - name: Build & push backend image + run: | + docker build \ + -t "$REGISTRY/$OWNER/archnest-backend:${{ github.sha }}" \ + -t "$REGISTRY/$OWNER/archnest-backend:latest" \ + -f backend/Dockerfile backend + docker push "$REGISTRY/$OWNER/archnest-backend:${{ github.sha }}" + docker push "$REGISTRY/$OWNER/archnest-backend:latest" + + - name: Log out + if: always() + run: docker logout "$REGISTRY" diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..0bd484b --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Deploy to racknerd2 + +# Manual-only. Pulls the pre-built images from the registry onto racknerd2 +# (validation host) over the NetBird mesh and restarts the stack. Build the +# images first with the "Build & Push Images" workflow. +# +# Requirements (see deploy/README.md): +# - Forgejo Actions secret RACKNERD2_SSH_KEY: private key authorized for +# root@racknerd2 (mesh IP 100.96.217.250). +# - racknerd2 already prepared: Docker installed, logged in to the registry, +# and /opt/archnest/{docker-compose.yml,.env} in place. + +on: + workflow_dispatch: + inputs: + tag: + description: "Image tag to deploy (commit SHA or 'latest')" + required: true + default: latest + +env: + DEPLOY_HOST: 100.96.217.250 + DEPLOY_DIR: /opt/archnest + +jobs: + deploy: + runs-on: docker + steps: + - name: Install SSH client + run: | + apt-get update + apt-get install -y --no-install-recommends openssh-client + + - name: Write deploy key + run: | + install -m 700 -d ~/.ssh + printf '%s\n' "${{ secrets.RACKNERD2_SSH_KEY }}" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + + - name: Pull images and restart stack + run: | + ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new \ + root@"$DEPLOY_HOST" \ + "cd $DEPLOY_DIR && ARCHNEST_TAG='${{ inputs.tag }}' docker compose pull && ARCHNEST_TAG='${{ inputs.tag }}' docker compose up -d --remove-orphans" + + - name: Health check (backend /api/health via mesh) + run: | + ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new \ + root@"$DEPLOY_HOST" \ + "for i in \$(seq 1 30); do curl -fsS http://$DEPLOY_HOST:8080/api/health && echo OK && exit 0; sleep 2; done; echo 'health check failed'; cd $DEPLOY_DIR && docker compose logs --tail=50; exit 1" diff --git a/.gitignore b/.gitignore index fc9be78..86af113 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ dist-ssr # Backend data/secrets backend/data backend/.env +# Env files (real secrets) — keep only the .example variants +.env +deploy/.env *.db *.db-journal *.db-wal diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..85fab4e --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,24 @@ +# Copy to `.env` next to deploy/docker-compose.yml ON racknerd2 (never commit the real .env). +# Compose loads it automatically. + +# Image tag to deploy. The build workflow pushes both :latest and the commit +# SHA; use :latest for rolling validation or pin a SHA for a specific build. +ARCHNEST_TAG=latest + +# Interface the app is published on. Mesh IP only — do NOT bind 0.0.0.0. +ARCHNEST_BIND_IP=100.96.217.250 + +# Origin the frontend is served from (used for CORS). Mesh URL for validation. +ARCHNEST_CORS_ORIGIN=http://100.96.217.250:8080 + +# 32-byte hex. Signs auth JWTs. Generate: openssl rand -hex 32 +ARCHNEST_JWT_SECRET= + +# 32-byte hex. Encrypts integration secrets at rest (AES-256-GCM). +# Changing this after data exists makes existing secrets undecryptable. +# Generate: openssl rand -hex 32 +ARCHNEST_SECRET_KEY= + +# Exactly 32 ASCII chars (used literally as an AES-256-CBC key for Guacamole). +# Generate: openssl rand -base64 24 | cut -c1-32 +ARCHNEST_GUAC_CRYPT_KEY= diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..bdedaf9 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,78 @@ +# ArchNest — Build & Deploy (Forgejo Actions → registry → racknerd2) + +This pipeline builds the Docker images in Forgejo Actions, pushes them to the +Forgejo container registry, and deploys them to **racknerd2** (validation host) +over the NetBird mesh. racknerd2 only pulls and runs — it never builds (1.9 GiB +RAM). + +``` +push to main / manual ─► [build.yml] build + push images ─► forgejo.snsnetlabs.com/sam/{archnest,archnest-backend} + │ + manual dispatch ─► [deploy.yml] ssh racknerd2 ─► docker compose pull && up -d +``` + +## Images + +| Image | From | Tags | +|-------|------|------| +| `forgejo.snsnetlabs.com/sam/archnest` | root `Dockerfile` (React build → nginx) | `latest`, `` | +| `forgejo.snsnetlabs.com/sam/archnest-backend` | `backend/Dockerfile` (Fastify) | `latest`, `` | + +Pushed images appear at `https://forgejo.snsnetlabs.com/sam/-/packages` (SSO). + +## One-time setup + +### 1. Forgejo Actions secrets (repo or org settings → Actions → Secrets) +- `FORGEJO_REGISTRY_TOKEN` — Forgejo personal access token for `sam` with + **package** scope (NOT the account password). Used by `build.yml` to log in + and push. +- `RACKNERD2_SSH_KEY` — private SSH key authorized for `root@racknerd2` + (mesh IP `100.96.217.250`). Used by `deploy.yml`. + +### 2. Runner (forgejo-runner host) — allow Docker builds +The runner runs jobs inside containers and by default has **no Docker access**. +Enable socket auto-mounting so the `build` job can build images. Create +`/opt/config.yaml` (or edit the existing runner config) with at least: + +```yaml +container: + docker_host: "automount" # mounts /var/run/docker.sock into job containers +``` + +Generate a full example with `forgejo-runner generate-config > /opt/config.yaml`, +set `docker_host: "automount"`, point the service at it +(`ExecStart=/usr/local/bin/forgejo-runner daemon -c /opt/config.yaml`), then +`systemctl daemon-reload && systemctl restart forgejo-runner`. + +### 3. racknerd2 — prepare the deploy host +Docker Engine + compose plugin are already installed. Then: + +```bash +mkdir -p /opt/archnest +# copy deploy/docker-compose.yml from this repo to /opt/archnest/docker-compose.yml +# create /opt/archnest/.env from deploy/.env.example and fill in the secrets: +# ARCHNEST_JWT_SECRET = openssl rand -hex 32 +# ARCHNEST_SECRET_KEY = openssl rand -hex 32 +# ARCHNEST_GUAC_CRYPT_KEY = openssl rand -base64 24 | cut -c1-32 +docker login forgejo.snsnetlabs.com # user: sam, password: the package token +``` + +Ports are bound to the **mesh IP only** (`100.96.217.250`) — Docker bypasses +ufw, so this is what keeps the app off the public interface. Validate at +`http://100.96.217.250:8080`. + +## Running it + +1. **Build**: push to `main`, or run **Build & Push Images** manually + (Actions tab → Run workflow). +2. **Deploy**: run **Deploy to racknerd2** manually, entering the tag + (`latest` or a specific commit SHA). It pulls, restarts, and health-checks + `/api/health`. + +## Notes / ceilings + +- `ponytail:` deploy is manual (workflow_dispatch), not auto-on-merge — this is + a validation host, so deploys are deliberate. Wire `build.yml` → `deploy.yml` + with `needs:` later if auto-deploy-to-validation is wanted. +- Single-arch (amd64) only — both the runner host and racknerd2 are amd64, so + no buildx/multi-platform is needed. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..6d875d6 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,54 @@ +# Deploy compose for racknerd2 (validation host). +# +# Unlike the root docker-compose.yml (which BUILDS images locally), this file +# PULLS pre-built images from the Forgejo container registry +# (forgejo.snsnetlabs.com/sam/...) that the Forgejo Actions `build` workflow +# pushes. racknerd2 only has ~1.9 GiB RAM, so we never build here. +# +# Usage on racknerd2 (in this file's directory, with a sibling .env): +# docker login forgejo.snsnetlabs.com # once, as user `sam` +# docker compose pull && docker compose up -d +# +# IMPORTANT: published ports are bound to the NetBird mesh IP only. Docker +# manipulates iptables directly and BYPASSES ufw, so a plain "8080:8080" would +# expose the port on the host's public interface regardless of the firewall. +# Binding to ${ARCHNEST_BIND_IP} keeps the app reachable only over the mesh. + +services: + archnest: + image: forgejo.snsnetlabs.com/sam/archnest:${ARCHNEST_TAG:-latest} + container_name: archnest + restart: unless-stopped + ports: + - "${ARCHNEST_BIND_IP:-100.96.217.250}:8080:8080" + depends_on: + - archnest-backend + + archnest-backend: + image: forgejo.snsnetlabs.com/sam/archnest-backend:${ARCHNEST_TAG:-latest} + container_name: archnest-backend + restart: unless-stopped + environment: + - PORT=4000 + - ARCHNEST_DB_PATH=/data/archnest.db + - ARCHNEST_JWT_SECRET=${ARCHNEST_JWT_SECRET} + - ARCHNEST_SECRET_KEY=${ARCHNEST_SECRET_KEY} + - ARCHNEST_CORS_ORIGIN=${ARCHNEST_CORS_ORIGIN:-http://100.96.217.250:8080} + - ARCHNEST_GUAC_CRYPT_KEY=${ARCHNEST_GUAC_CRYPT_KEY} + - ARCHNEST_GUACD_HOST=guacd + - ARCHNEST_GUACD_PORT=4822 + volumes: + - archnest-data:/data + # No host port published: the frontend container reaches the backend over + # the compose network as "archnest-backend:4000" (nginx proxies /api). + depends_on: + - guacd + + guacd: + image: guacamole/guacd:1.5.5 + container_name: archnest-guacd + restart: unless-stopped + # Internal only; reachable as "guacd:4822" on the compose network. + +volumes: + archnest-data: