Compare commits

..

7 Commits

10 changed files with 389 additions and 6 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.next
.git
*.log
logs
.env
.env.*
!.env.example
.env.local
.env.*.local
.vscode
.idea
.DS_Store

View File

@@ -1,14 +1,22 @@
NEXT_PUBLIC_SUPABASE_URL= NODE_ENV=production
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
PLESK_CRED_ENC_KEY= PLESK_CRED_ENC_KEY=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
CRON_SHARED_SECRET=
# Existing runtime keys used by current codebase
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
STRIPE_PRICE_ID= STRIPE_PRICE_ID=

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM cgr.dev/chainguard/node:20-dev AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM cgr.dev/chainguard/node:20-dev AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM cgr.dev/chainguard/node:20-dev AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM cgr.dev/chainguard/node:20 AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.mjs ./next.config.mjs
COPY --from=builder /app/.next ./.next
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3000"]

85
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,85 @@
pipeline {
agent any
environment {
REGISTRY_IMAGE = "plesk-agency-portal"
IMAGE_LATEST = "${REGISTRY_IMAGE}:latest"
IMAGE_BUILD = "${REGISTRY_IMAGE}:build-${BUILD_NUMBER}"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install dependencies') {
steps {
sh 'npm ci'
}
}
stage('Lint') {
steps {
script {
def hasLint = sh(script: "node -e \"const fs=require('fs');const p=require('./package.json');process.exit(p?.scripts?.lint?0:1)\"", returnStatus: true)
if (hasLint == 0) {
sh 'npm run lint'
} else {
echo 'No lint script configured; skipping lint stage.'
}
}
}
}
stage('Build application') {
steps {
sh 'npm run build'
}
}
stage('Build Docker image') {
steps {
sh 'docker build -t ${IMAGE_LATEST} -t ${IMAGE_BUILD} .'
}
}
stage('Deploy') {
steps {
script {
if (!env.DEPLOY_SSH_CREDENTIAL_ID) {
error 'DEPLOY_SSH_CREDENTIAL_ID is required'
}
}
sshagent(credentials: ["${env.DEPLOY_SSH_CREDENTIAL_ID}"]) {
sh '''
set -euo pipefail
: "${DEPLOY_SSH_HOST:?Missing DEPLOY_SSH_HOST}"
: "${DEPLOY_SSH_USER:?Missing DEPLOY_SSH_USER}"
: "${DEPLOY_PATH:?Missing DEPLOY_PATH}"
rsync -az \
docker-compose.prod.yml \
scripts/deploy-prod.sh \
scripts/smoke-check.sh \
scripts/rollback-prod.sh \
${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:${DEPLOY_PATH}/
docker save ${IMAGE_LATEST} ${IMAGE_BUILD} \
| ssh ${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST} 'docker load'
ssh ${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST} \
"chmod +x ${DEPLOY_PATH}/deploy-prod.sh ${DEPLOY_PATH}/smoke-check.sh ${DEPLOY_PATH}/rollback-prod.sh && APP_IMAGE='${IMAGE_BUILD}' DEPLOY_PATH='${DEPLOY_PATH}' ${DEPLOY_PATH}/deploy-prod.sh"
'''
}
}
}
stage('Smoke check') {
steps {
sh 'chmod +x scripts/smoke-check.sh && scripts/smoke-check.sh ${APP_BASE_URL}'
}
}
}
}

13
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json(
{
ok: true,
service: "plesk-agency-portal",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV ?? "development",
},
{ status: 200 },
);
}

24
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
web:
container_name: plesk-agency-portal
image: ${APP_IMAGE:-plesk-agency-portal:latest}
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
ports:
- "3000:3000"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s

View File

@@ -0,0 +1,121 @@
# Production Deployment Runtime Foundation
This project can be deployed in a dedicated Proxmox container using Docker Compose. The files added here establish a minimal production runtime baseline without changing application business logic or Supabase security boundaries.
## Dockerfile purpose
The `Dockerfile` uses a multi-stage build on Node 20:
- installs dependencies
- runs `next build`
- prepares a lean runtime image with production dependencies
- runs the app with `next start` on port `3000`
This keeps the runtime image small and focused on serving the built Next.js app.
## docker-compose.prod.yml purpose
`docker-compose.prod.yml` defines one service:
- service name: `web`
- container name: `plesk-agency-portal`
- restart policy: `unless-stopped`
- environment from `.env`
- port mapping `3000:3000`
- health check against `/api/health`
## External Supabase requirement
Supabase remains external to this application container. The app container must only connect to Supabase via environment variables. No local Supabase container is included in this production compose file.
## Health endpoint usage
`GET /api/health` provides a fast, dependency-light runtime check for container orchestration and uptime checks.
Example:
```bash
curl -s http://localhost:3000/api/health
```
Expected response shape:
- `ok: true`
- `service: "plesk-agency-portal"`
- `timestamp`
- `environment`
## Production environment expectations
Before deploying, set production-safe values in `.env` (using `.env.example` as the template), including:
- app URL and `NODE_ENV`
- Supabase public and server credentials
- Plesk credential encryption key
- Stripe secrets
- cron/job shared secrets
Keep secrets out of source control.
## Jenkins pipeline flow
The repository includes a declarative `Jenkinsfile` with the following stages:
1. **Checkout**
2. **Install dependencies** (`npm ci`)
3. **Lint** (runs only when `package.json` contains a `lint` script)
4. **Build application** (`npm run build`)
5. **Build Docker image** (tags `latest` and `build-${BUILD_NUMBER}`)
6. **Deploy** over SSH to the app host
7. **Smoke check** against `/api/health`
## Required Jenkins configuration
Configure these values in Jenkins job/folder/global environment or credentials-backed environment variables:
- `DEPLOY_SSH_HOST` - remote app host
- `DEPLOY_SSH_USER` - SSH user on remote host
- `DEPLOY_SSH_CREDENTIAL_ID` - Jenkins SSH Agent credential ID
- `DEPLOY_PATH` - remote deployment path (for example `/opt/plesk-agency-portal`)
- `APP_BASE_URL` - externally reachable app URL used by smoke check
The pipeline assumes Jenkins credentials are managed outside this repository.
## Deploy flow
Deployment is intentionally simple and bash-based:
1. Jenkins builds Docker image tags locally.
2. Jenkins syncs deployment files (`docker-compose.prod.yml`, deploy/smoke/rollback scripts) to the remote deploy path.
3. Jenkins streams Docker images to the remote host (`docker save | ssh docker load`).
4. Jenkins runs `scripts/deploy-prod.sh` remotely.
5. Script applies the selected image tag via:
```bash
APP_IMAGE=<tag> docker compose -f docker-compose.prod.yml up -d --no-build
```
6. Script prints `docker compose ps` status.
## Rollback flow
Rollback uses `scripts/rollback-prod.sh` with a previously built image tag:
```bash
DEPLOY_PATH=/opt/plesk-agency-portal \
APP_BASE_URL=https://your-app.example.com \
scripts/rollback-prod.sh plesk-agency-portal:build-123
```
Rollback redeploys that image with compose, then immediately reruns smoke checks.
## Smoke check behavior
`scripts/smoke-check.sh`:
- accepts a base URL argument
- requests `${BASE_URL}/api/health`
- fails on non-200 responses
- fails on invalid JSON or missing expected fields (`ok: true`, `service` string)
This gives a quick post-deploy validation gate for both deploy and rollback operations.

30
scripts/deploy-prod.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_IMAGE="${APP_IMAGE:-plesk-agency-portal:latest}"
SOURCE_PATH="${SOURCE_PATH:-${DEPLOY_PATH}}"
mkdir -p "${DEPLOY_PATH}"
for file in docker-compose.prod.yml deploy-prod.sh smoke-check.sh rollback-prod.sh; do
if [ -f "${SOURCE_PATH}/${file}" ]; then
cp "${SOURCE_PATH}/${file}" "${DEPLOY_PATH}/${file}"
fi
done
chmod +x "${DEPLOY_PATH}/deploy-prod.sh" \
"${DEPLOY_PATH}/smoke-check.sh" \
"${DEPLOY_PATH}/rollback-prod.sh" 2>/dev/null || true
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}"
APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-build
docker compose -f docker-compose.prod.yml ps

30
scripts/rollback-prod.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
PREVIOUS_IMAGE_TAG="${1:-}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_BASE_URL="${APP_BASE_URL:-}"
if [ -z "${PREVIOUS_IMAGE_TAG}" ]; then
echo "Usage: $0 <previous-image-tag>" >&2
exit 1
fi
if [ -z "${APP_BASE_URL}" ]; then
echo "APP_BASE_URL is required" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}"
APP_IMAGE="${PREVIOUS_IMAGE_TAG}" docker compose -f docker-compose.prod.yml up -d --no-build
"${DEPLOY_PATH}/smoke-check.sh" "${APP_BASE_URL}"
docker compose -f docker-compose.prod.yml ps

27
scripts/smoke-check.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-}"
if [ -z "${BASE_URL}" ]; then
echo "Usage: $0 <base-url>" >&2
exit 1
fi
URL="${BASE_URL%/}/api/health"
TMP_FILE="$(mktemp)"
trap 'rm -f "${TMP_FILE}"' EXIT
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}")
if [ "${HTTP_CODE}" != "200" ]; then
echo "Smoke check failed: ${URL} returned ${HTTP_CODE}" >&2
cat "${TMP_FILE}" >&2 || true
exit 1
fi
node -e "const fs=require('fs');const d=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(d?.ok!==true||typeof d?.service!=='string'){process.exit(1)}" "${TMP_FILE}"
echo "Smoke check passed: ${URL}"