Production deploy: nginx /api proxy, native-module toolchain, hardened CI
The frontend calls the API with relative paths (fetch('/api/...')), so in
production those requests hit the nginx frontend container on :8080 — which
previously only served the SPA and would 404 every API call and WebSocket
route. nginx.conf now proxies /api/ to the archnest-backend service with
WebSocket upgrade support, long timeouts for terminals/tunnels/transfers, and
a 1GB body limit matching the backend's upload cap.
The backend Dockerfile now installs python3/make/g++ in both the build and
runtime stages so the native modules (better-sqlite3, ssh2, node-pty) compile
on alpine instead of crashing the container at startup.
The deploy workflow gains a validate job (type-check + build both apps before
touching the host), a pre-flight check that refuses to deploy without the
host-side .env, and a post-deploy health check against /api/health and the
frontend, with concurrency guarding.
This commit is contained in:
parent
c834d03752
commit
ef5e497554
3 changed files with 145 additions and 4 deletions
109
.github/workflows/deploy.yml
vendored
109
.github/workflows/deploy.yml
vendored
|
|
@ -1,20 +1,89 @@
|
||||||
name: Deploy to racknerd1
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
# Prevent overlapping deploys clobbering each other.
|
||||||
|
concurrency:
|
||||||
|
group: deploy-racknerd1
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DEPLOY_PATH: /opt/archnest
|
DEPLOY_PATH: /opt/archnest
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
# Fail fast on build/type errors before touching the server.
|
||||||
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Copy repo to racknerd1
|
||||||
uses: appleboy/scp-action@v0.1.7
|
uses: appleboy/scp-action@v0.1.7
|
||||||
with:
|
with:
|
||||||
|
|
@ -24,9 +93,25 @@ jobs:
|
||||||
port: ${{ secrets.RACKNERD_PORT || 22 }}
|
port: ${{ secrets.RACKNERD_PORT || 22 }}
|
||||||
source: "."
|
source: "."
|
||||||
target: ${{ env.DEPLOY_PATH }}
|
target: ${{ env.DEPLOY_PATH }}
|
||||||
|
# Keep the host-only .env (and any other untracked host state) intact.
|
||||||
rm: false
|
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
|
uses: appleboy/ssh-action@v1.2.0
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.RACKNERD_HOST }}
|
host: ${{ secrets.RACKNERD_HOST }}
|
||||||
|
|
@ -34,6 +119,22 @@ jobs:
|
||||||
key: ${{ secrets.RACKNERD_SSH_KEY }}
|
key: ${{ secrets.RACKNERD_SSH_KEY }}
|
||||||
port: ${{ secrets.RACKNERD_PORT || 22 }}
|
port: ${{ secrets.RACKNERD_PORT || 22 }}
|
||||||
script: |
|
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 }}
|
cd ${{ env.DEPLOY_PATH }}
|
||||||
docker compose up -d --build
|
docker compose ps || true
|
||||||
docker image prune -f
|
docker compose logs --tail=80 || true
|
||||||
|
exit 1
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
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* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --omit=dev=false
|
RUN npm install --omit=dev=false
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
@ -8,6 +10,9 @@ RUN npm run build
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
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* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
|
||||||
35
nginx.conf
35
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 {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue