diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 56597a7..7ff00cb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,20 +1,89 @@ name: Deploy to racknerd1 +# Deploys ArchNest (frontend + backend + guacd) to racknerd1 via Docker Compose. +# +# Triggers: +# - push to main (automatic) +# - manual run from the Actions tab (workflow_dispatch) +# +# Required GitHub Actions repo secrets (Settings -> Secrets and variables -> Actions): +# RACKNERD_HOST - racknerd1 hostname or IP the runner can SSH to +# RACKNERD_USER - deploy SSH user (must be in the docker group) +# RACKNERD_SSH_KEY - private SSH key (PEM) for that user +# RACKNERD_PORT - SSH port (optional, defaults to 22) +# +# One-time host setup (NOT done by this workflow, see README Deployment section): +# - Docker + Docker Compose installed, deploy user in the docker group +# - mkdir -p /opt/archnest +# - Create /opt/archnest/.env from .env.example with real generated secrets +# (ARCHNEST_JWT_SECRET, ARCHNEST_SECRET_KEY, ARCHNEST_GUAC_CRYPT_KEY, ...). +# This workflow refuses to deploy if that file is missing, and never +# overwrites it, so live secrets/data are safe across deploys. + on: push: branches: [main] workflow_dispatch: {} +# Prevent overlapping deploys clobbering each other. +concurrency: + group: deploy-racknerd1 + cancel-in-progress: false + env: DEPLOY_PATH: /opt/archnest jobs: - deploy: + # Fail fast on build/type errors before touching the server. + validate: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install + type-check + build frontend + run: | + npm ci + npx tsc --noEmit + npm run build + + - name: Install + type-check + build backend + working-directory: backend + run: | + npm ci + npx tsc --noEmit + npm run build + + deploy: + needs: validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Pre-flight - confirm host .env exists (don't deploy without secrets) + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.RACKNERD_HOST }} + username: ${{ secrets.RACKNERD_USER }} + key: ${{ secrets.RACKNERD_SSH_KEY }} + port: ${{ secrets.RACKNERD_PORT || 22 }} + script: | + set -e + mkdir -p ${{ env.DEPLOY_PATH }} + if [ ! -f ${{ env.DEPLOY_PATH }}/.env ]; then + echo "::error::Missing ${{ env.DEPLOY_PATH }}/.env on the host." + echo "Create it from .env.example with real secrets before deploying." + echo "It is intentionally never created/overwritten by this workflow." + exit 1 + fi + echo ".env present - proceeding." + - name: Copy repo to racknerd1 uses: appleboy/scp-action@v0.1.7 with: @@ -24,9 +93,25 @@ jobs: port: ${{ secrets.RACKNERD_PORT || 22 }} source: "." target: ${{ env.DEPLOY_PATH }} + # Keep the host-only .env (and any other untracked host state) intact. rm: false + overwrite: true - - name: Build and restart container + - name: Build, restart, and clean up + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.RACKNERD_HOST }} + username: ${{ secrets.RACKNERD_USER }} + key: ${{ secrets.RACKNERD_SSH_KEY }} + port: ${{ secrets.RACKNERD_PORT || 22 }} + command_timeout: 20m + script: | + set -e + cd ${{ env.DEPLOY_PATH }} + docker compose up -d --build --remove-orphans + docker image prune -f + + - name: Health check (backend /api/health) uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.RACKNERD_HOST }} @@ -34,6 +119,22 @@ jobs: key: ${{ secrets.RACKNERD_SSH_KEY }} port: ${{ secrets.RACKNERD_PORT || 22 }} script: | + set -e + echo "Waiting for backend to become healthy..." + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:4000/api/health >/dev/null 2>&1; then + echo "Backend healthy." + # Confirm the frontend container is serving too. + if curl -fsS http://127.0.0.1:8080/ >/dev/null 2>&1; then + echo "Frontend healthy. Deploy succeeded." + exit 0 + fi + echo "Frontend not ready yet..." + fi + sleep 5 + done + echo "::error::Health check failed after ~150s. Dumping container status + logs." cd ${{ env.DEPLOY_PATH }} - docker compose up -d --build - docker image prune -f + docker compose ps || true + docker compose logs --tail=80 || true + exit 1 diff --git a/backend/Dockerfile b/backend/Dockerfile index b2814f7..76bbd15 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,7 @@ FROM node:22-alpine AS build WORKDIR /app +# Native modules (better-sqlite3, ssh2, node-pty) need a toolchain to compile. +RUN apk add --no-cache python3 make g++ COPY package.json package-lock.json* ./ RUN npm install --omit=dev=false COPY . . @@ -8,6 +10,9 @@ RUN npm run build FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production +# Toolchain is needed again here: production deps are reinstalled fresh, and the +# native modules (better-sqlite3, ssh2, node-pty) compile from source on install. +RUN apk add --no-cache python3 make g++ COPY package.json package-lock.json* ./ RUN npm install --omit=dev COPY --from=build /app/dist ./dist diff --git a/nginx.conf b/nginx.conf index cfb23c8..aec71bb 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,9 +1,44 @@ +# Maps the Upgrade header to the correct Connection value for WebSocket proxying. +# Lives at the http level (this file is included from nginx's http block). +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 8080; server_name _; root /usr/share/nginx/html; index index.html; + # API + WebSocket routes are proxied to the backend container. + # The frontend calls the API with relative paths (fetch('/api/...')), so + # everything arrives on :8080 and nginx forwards /api to archnest-backend. + # "archnest-backend" is the Docker Compose service name on the shared network. + location /api/ { + proxy_pass http://archnest-backend:4000; + proxy_http_version 1.1; + + # WebSocket upgrade support (/api/terminal, /api/docker/exec, + # /api/guacamole, /api/tunnels live data, etc.) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Long-lived connections for terminals/tunnels/file transfers. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + + # Large file uploads via the file manager (backend allows up to 1GB). + client_max_body_size 1024m; + } + + # SPA fallback: serve index.html for client-side routes. location / { try_files $uri $uri/ /index.html; }