feat(deploy): app stack + manual Portainer deploy workflow
Add the missing api/web deployment path on top of the existing image build CI. - deploy/jorgecuadros-app.stack.yml: PROD app stack (api + web) pulling the git.mancinas.io registry images. Does not ship mysql/minio (separate stacks); API reaches them via DATABASE_URL / S3_ENDPOINT. API pinned to the jorgecuadros_db node for stable ingest/backup volumes; web is stateless. - deploy/jorgecuadros-app.env.example: documented stack env template. - .gitea/workflows/deploy.yml: manual (workflow_dispatch) deploy to Portainer via cssnr/portainer-stack-deploy-action. Inputs: image tag + scope (app = web+api, full = db+minio+app, applied db->minio->app). Make the web API origin runtime-configurable instead of build-baked: the root layout injects window.__API_ORIGIN__ from the API_ORIGIN env (force-dynamic) and lib/api.ts resolves it at runtime, so one built image serves any deployment. Also: dev.sh to run both dev servers (frees stale ports first) and move local dev to ports web 4500 / api 4501. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
|||||||
|
# Manual PROD deploy to Portainer.
|
||||||
|
#
|
||||||
|
# This does NOT build — build.yml already builds + pushes the api/web images.
|
||||||
|
# This workflow (re)applies the deploy/*.stack.yml files to the Portainer Swarm.
|
||||||
|
# Trigger it by hand from the Actions tab ("Run workflow") and choose:
|
||||||
|
# - tag: which already-published image tag to ship (default: latest)
|
||||||
|
# - scope: how much to deploy
|
||||||
|
# app = web + api only (the usual app release) [default]
|
||||||
|
# full = db + minio + web + api (bring up / update the whole platform)
|
||||||
|
#
|
||||||
|
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates
|
||||||
|
# it on every run, so no manual stack pre-creation in the Portainer UI. On a
|
||||||
|
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
|
||||||
|
# on them). db + minio are stateful + pinned to node label jorgecuadros_db=true
|
||||||
|
# (see their stack files) — re-applying them is idempotent and keeps their data.
|
||||||
|
#
|
||||||
|
# Prereqs (once):
|
||||||
|
# - one swarm node labelled jorgecuadros_db=true (db + minio + api pin there).
|
||||||
|
# - Gitea repo secrets set (Settings > Actions > Secrets):
|
||||||
|
# # Portainer
|
||||||
|
# PORTAINER_URL https://192.168.4.212:9443
|
||||||
|
# PORTAINER_API_KEY Portainer access token
|
||||||
|
# PORTAINER_ENDPOINT_ID 2 (the local Swarm endpoint)
|
||||||
|
# PORTAINER_APP_STACK_NAME e.g. jorgecuadros-prod-app
|
||||||
|
# PORTAINER_DB_STACK_NAME e.g. jorgecuadros-prod-db (full only)
|
||||||
|
# PORTAINER_MINIO_STACK_NAME e.g. jorgecuadros-prod-minio (full only)
|
||||||
|
# # App runtime
|
||||||
|
# DATABASE_URL mysql://jorgecuadros:<pass>@192.168.4.212:3306/jorgecuadros
|
||||||
|
# SESSION_SECRET 64-hex (openssl rand -hex 32)
|
||||||
|
# APP_API_ORIGIN http://192.168.4.212:3001 (browser-facing API URL)
|
||||||
|
# APP_WEB_ORIGIN http://192.168.4.212:3000 (web public origin, API CORS)
|
||||||
|
# APP_S3_ENDPOINT http://192.168.4.212:9000 (server-side minio URL)
|
||||||
|
# # Object storage (app + minio stack)
|
||||||
|
# MINIO_ROOT_USER minio access key
|
||||||
|
# MINIO_ROOT_PASSWORD minio secret key
|
||||||
|
# # Database stack (full only)
|
||||||
|
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
||||||
|
# MYSQL_ROOT_PASSWORD mysql root password
|
||||||
|
|
||||||
|
name: Deploy to Portainer
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Image tag to deploy (latest, sha-<short>, or vX.Y.Z)"
|
||||||
|
required: true
|
||||||
|
default: "latest"
|
||||||
|
scope:
|
||||||
|
description: "What to deploy"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: "app"
|
||||||
|
options:
|
||||||
|
- app
|
||||||
|
- full
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.mancinas.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy (${{ github.event.inputs.scope }})
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:18-alpine
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# --- full only: database ---------------------------------------------
|
||||||
|
- name: Deploy database stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
||||||
|
file: deploy/jorgecuadros-db.stack.yml
|
||||||
|
type: file
|
||||||
|
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MYSQL_SERVER_ID": "1",
|
||||||
|
"MYSQL_PORT": "3306",
|
||||||
|
"MYSQL_DATABASE": "jorgecuadros",
|
||||||
|
"MYSQL_USER": "jorgecuadros",
|
||||||
|
"MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}",
|
||||||
|
"MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- full only: object storage ---------------------------------------
|
||||||
|
- name: Deploy minio stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
||||||
|
file: deploy/jorgecuadros-minio.stack.yml
|
||||||
|
type: file
|
||||||
|
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MINIO_API_PORT": "9000",
|
||||||
|
"MINIO_CONSOLE_PORT": "9001",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- always: the app (web + api) -------------------------------------
|
||||||
|
- name: Deploy app stack
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
||||||
|
file: deploy/jorgecuadros-app.stack.yml
|
||||||
|
type: file
|
||||||
|
pull_image: true
|
||||||
|
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
|
"API_PORT": "3001",
|
||||||
|
"WEB_PORT": "3000",
|
||||||
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN }}",
|
||||||
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
||||||
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
||||||
|
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
||||||
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET }}",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 4500",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
|
|||||||
@@ -7,10 +7,29 @@ export const metadata = {
|
|||||||
"Plataforma interna unificada de clientes, servicios y seguros.",
|
"Plataforma interna unificada de clientes, servicios y seguros.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The browser talks to the API cross-origin, so it needs the API URL at
|
||||||
|
// runtime. NEXT_PUBLIC_* would bake it at build time (one URL per image); we
|
||||||
|
// want the URL to come from the deploy .env instead. So read it here on the
|
||||||
|
// server per request and inject it as window.__API_ORIGIN__ (see lib/api.ts).
|
||||||
|
// force-dynamic guarantees process.env is read at request time, never baked
|
||||||
|
// into a static prerender.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
const apiOrigin =
|
||||||
|
process.env.API_ORIGIN ??
|
||||||
|
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||||
|
"http://localhost:3001";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
|
{/* Must run before the app bundle so lib/api.ts sees it at import. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Google Fonts via <link> so an offline build still runs with the
|
{/* Google Fonts via <link> so an offline build still runs with the
|
||||||
system fallback stacks defined in globals.css. */}
|
system fallback stacks defined in globals.css. */}
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
+18
-2
@@ -55,8 +55,24 @@ import type {
|
|||||||
UserRow,
|
UserRow,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const API_ORIGIN =
|
// Resolve the API origin at runtime, not build time. In the browser it comes
|
||||||
process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:3001";
|
// from window.__API_ORIGIN__, injected server-side by the root layout from the
|
||||||
|
// deploy .env (API_ORIGIN) — so one built image serves any deployment. On the
|
||||||
|
// server (SSR) read process.env directly. NEXT_PUBLIC_API_ORIGIN stays as the
|
||||||
|
// dev/build fallback.
|
||||||
|
function resolveApiOrigin(): string {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__;
|
||||||
|
if (injected) return injected;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
process.env.API_ORIGIN ??
|
||||||
|
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||||
|
"http://localhost:3001"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_ORIGIN = resolveApiOrigin();
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Stack env for deploy/jorgecuadros-app.stack.yml (PROD).
|
||||||
|
# Paste these into the Portainer stack's "Environment variables" at deploy time.
|
||||||
|
# Do NOT commit real secrets — this file is a template only.
|
||||||
|
#
|
||||||
|
# HOST below = the swarm host the db/minio/app stacks publish on (cubex).
|
||||||
|
|
||||||
|
# Which built image tag to run. latest = default-branch build; or pin sha-<x> / vX.Y.Z.
|
||||||
|
APP_TAG=latest
|
||||||
|
|
||||||
|
# --- Public URLs (what the end user's BROWSER hits) ---------------------------
|
||||||
|
# API_ORIGIN is injected into the web app at runtime and used for browser fetches
|
||||||
|
# + document download links, so it must be browser-reachable (not swarm-internal).
|
||||||
|
# WEB_ORIGIN is the web app's own public origin; the API allows it via CORS.
|
||||||
|
API_ORIGIN=http://192.168.4.212:3001
|
||||||
|
WEB_ORIGIN=http://192.168.4.212:3000
|
||||||
|
|
||||||
|
# Published ports on the swarm host.
|
||||||
|
API_PORT=3001
|
||||||
|
WEB_PORT=3000
|
||||||
|
|
||||||
|
# --- Database (points at the jorgecuadros-prod-db stack) ----------------------
|
||||||
|
# prod db publishes 3306 on the host (see deploy/jorgecuadros-db.stack.yml).
|
||||||
|
DATABASE_URL=mysql://jorgecuadros:CHANGE_ME@192.168.4.212:3306/jorgecuadros
|
||||||
|
|
||||||
|
# --- Auth --------------------------------------------------------------------
|
||||||
|
# 64-hex random. Generate: openssl rand -hex 32
|
||||||
|
SESSION_SECRET=CHANGE_ME
|
||||||
|
|
||||||
|
# --- Object storage (points at the jorgecuadros-prod-minio stack) -------------
|
||||||
|
# Server-side only; prod minio API publishes 9000 on the host.
|
||||||
|
S3_ENDPOINT=http://192.168.4.212:9000
|
||||||
|
S3_BUCKET=jorgecuadros-documents
|
||||||
|
MINIO_ROOT_USER=jc_minio
|
||||||
|
MINIO_ROOT_PASSWORD=CHANGE_ME
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Application stack for the Jorge Cuadros platform: the NestJS API + the Next.js
|
||||||
|
# web front-end. The two images are built + pushed by .gitea/workflows/build.yml:
|
||||||
|
# git.mancinas.io/rmancinas/jorgecuadros-api
|
||||||
|
# git.mancinas.io/rmancinas/jorgecuadros-web
|
||||||
|
#
|
||||||
|
# This stack does NOT ship MySQL or MinIO — those are their own stacks
|
||||||
|
# (deploy/jorgecuadros-db.stack.yml, deploy/jorgecuadros-minio.stack.yml). The
|
||||||
|
# API reaches them over the network via DATABASE_URL / S3_ENDPOINT, which point
|
||||||
|
# at the db + minio stacks' published ingress ports on the swarm host.
|
||||||
|
#
|
||||||
|
# Target: Portainer local endpoint on cubex (3-node Swarm). PROD only.
|
||||||
|
# Deploy with a stack env that supplies every ${VAR:?...} below — see
|
||||||
|
# deploy/jorgecuadros-app.env.example for the full list.
|
||||||
|
#
|
||||||
|
# Statefulness: the API keeps uploaded Access files (ingest) and DB backups on
|
||||||
|
# named volumes, which are node-local. So the API is pinned to the same node as
|
||||||
|
# the db/minio stacks (node label jorgecuadros_db == true) — a reschedule would
|
||||||
|
# otherwise start against empty ingest/backup volumes. The web tier is
|
||||||
|
# stateless and floats freely.
|
||||||
|
#
|
||||||
|
# The web image is NOT URL-baked: the browser's API origin is injected at
|
||||||
|
# runtime from API_ORIGIN (see apps/web/src/app/layout.tsx), so this same image
|
||||||
|
# works for any deployment — set the URL here, not at build time.
|
||||||
|
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
|
# CORS: the public origin the browser loads the web app from.
|
||||||
|
WEB_ORIGIN: ${WEB_ORIGIN:?WEB_ORIGIN must be set}
|
||||||
|
PORT: "3001"
|
||||||
|
INGEST_DIR: /data/ingest
|
||||||
|
BACKUP_DIR: /data/backups
|
||||||
|
MIGRATION_ENV: prod
|
||||||
|
# Object storage — internal endpoint the API (server-side) uses to reach
|
||||||
|
# the minio stack. Not browser-facing (downloads proxy through the API).
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||||
|
ports:
|
||||||
|
- target: 3001
|
||||||
|
published: ${API_PORT:-3001}
|
||||||
|
protocol: tcp
|
||||||
|
mode: ingress
|
||||||
|
volumes:
|
||||||
|
- ingest_data:/data/ingest
|
||||||
|
- backup_data:/data/backups
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.labels.jorgecuadros_db == true
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
update_config:
|
||||||
|
order: stop-first
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
||||||
|
environment:
|
||||||
|
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
||||||
|
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
||||||
|
ports:
|
||||||
|
- target: 3000
|
||||||
|
published: ${WEB_PORT:-3000}
|
||||||
|
protocol: tcp
|
||||||
|
mode: ingress
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
update_config:
|
||||||
|
order: start-first
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ingest_data:
|
||||||
|
backup_data:
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Start the development servers (API + web).
|
||||||
|
# Runs both in parallel and shuts both down on Ctrl-C.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
WEB_PORT=4500
|
||||||
|
API_PORT=4501
|
||||||
|
|
||||||
|
api_pid=""
|
||||||
|
web_pid=""
|
||||||
|
|
||||||
|
# Free a port by killing whatever is listening on it (stale dev servers).
|
||||||
|
free_port() {
|
||||||
|
local port="$1"
|
||||||
|
local pids
|
||||||
|
pids="$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)"
|
||||||
|
if [ -n "$pids" ]; then
|
||||||
|
echo "Freeing port $port (killing: $pids)"
|
||||||
|
kill $pids 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kill the child servers once, on Ctrl-C or exit.
|
||||||
|
cleanup() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
echo ""
|
||||||
|
echo "Shutting down dev servers..."
|
||||||
|
[ -n "$api_pid" ] && kill "$api_pid" 2>/dev/null || true
|
||||||
|
[ -n "$web_pid" ] && kill "$web_pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
free_port "$API_PORT"
|
||||||
|
free_port "$WEB_PORT"
|
||||||
|
|
||||||
|
echo "Starting API -> http://localhost:$API_PORT"
|
||||||
|
pnpm --filter @jorgecuadros/api start:dev &
|
||||||
|
api_pid=$!
|
||||||
|
|
||||||
|
echo "Starting web -> http://localhost:$WEB_PORT"
|
||||||
|
pnpm --filter @jorgecuadros/web dev &
|
||||||
|
web_pid=$!
|
||||||
|
|
||||||
|
# Wait for both. Ctrl-C fires the trap, which kills them.
|
||||||
|
wait
|
||||||
Reference in New Issue
Block a user