feat(deploy): switch to Oracle MySQL, separate CI runner, docker-compose deploy

- Prisma schema: sqlite → mysql provider
- Migration SQL rewritten as MySQL DDL (utf8mb4, DATETIME(3), AUTO_INCREMENT)
- Add migration_lock.toml for mysql provider
- Dockerfile.backend: drop node:22/sqlite deps, use node:20-slim
- entrypoint.sh: replace SQLite first-run logic with prisma migrate deploy + db seed
- docker-compose.prod.yml: production compose for /opt/webapps/okr on web VPS
  - reads DB creds from /opt/webapps/webapp-mysql.env
  - reads app secrets from /opt/webapps/okr/.env.app (written by CI)
  - port 80 (frontend), no conflict with Gitea 3000/Vault 8200
- ci.yml deploy-okr: moves from ubuntu-latest (web VPS) to ci-runner (161.33.149.243)
  - builds images on CI runner VPS (no heavy build on web/Gitea VPS)
  - transfers images via docker save | gzip | ssh | docker load
  - deploys via SSH + docker compose up on web VPS
- scripts/setup-ci-runner.sh: one-time setup script for CI runner VPS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-01 16:37:56 +09:00
co-authored by Claude Sonnet 4.6
parent 719f1147d1
commit 9229fee656
8 changed files with 284 additions and 122 deletions
+2 -10
View File
@@ -1,5 +1,5 @@
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
FROM node:22-slim AS builder
FROM node:20-slim AS builder
WORKDIR /app
@@ -15,13 +15,10 @@ RUN npm ci
COPY backend/src ./backend/src
COPY backend/tsconfig*.json ./backend/
# Generate Prisma client + compile TypeScript
RUN cd backend && npx prisma generate && npx tsc -p tsconfig.build.json
# ─── Stage 2: Runtime ────────────────────────────────────────────────────────
FROM node:22-slim AS runtime
# node:sqlite (setup-sqlite.mjs) requires Node 22 — this image satisfies that.
FROM node:20-slim AS runtime
WORKDIR /app
@@ -32,24 +29,19 @@ COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY backend/prisma ./backend/prisma
# All deps (including devDeps) so that tsx (seed) and prisma CLI are available.
RUN npm ci --ignore-scripts
# Generate Prisma client in runtime image (CWD resolves schema at backend/prisma/schema.prisma)
RUN cd backend && npx prisma generate
COPY --from=builder /app/backend/dist ./backend/dist
COPY backend/scripts ./backend/scripts
COPY backend/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
WORKDIR /app/backend
VOLUME ["/data"]
EXPOSE 3001
ENV PORT=3001
ENV DATABASE_URL=file:/data/okr.db
ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"]
+7 -12
View File
@@ -1,21 +1,16 @@
#!/bin/sh
# OKR backend container entrypoint.
# - First run: creates SQLite schema via setup-sqlite.mjs + seeds initial data.
# - Subsequent runs: DB already exists, skip init.
# WORKDIR expected: /app/backend (set in Dockerfile)
# OKR backend container entrypoint for MySQL.
# - Applies pending Prisma migrations (idempotent).
# - Seeds initial data via upsert (safe to run on every start).
set -e
# Hoisted node_modules/.bin (workspace root) must be in PATH for tsx + prisma CLI
export PATH="/app/node_modules/.bin:$PATH"
mkdir -p /data
echo "[OKR] Applying database migrations..."
npx prisma migrate deploy
if [ ! -f "/data/okr.db" ]; then
echo "[OKR] First run — initializing database at /data/okr.db"
node scripts/setup-sqlite.mjs
npx prisma db seed
echo "[OKR] Database initialized"
fi
echo "[OKR] Seeding database..."
npx prisma db seed
echo "[OKR] Starting backend on port ${PORT:-3001}"
exec node dist/main.js
@@ -1,53 +1,65 @@
-- Initial OKR SQLite schema generated from prisma/schema.prisma via prisma migrate diff.
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- MySQL initial schema for OKR application
CREATE TABLE `User` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`username` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`passwordHash` VARCHAR(191) NOT NULL,
`role` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `User_username_key`(`username`),
UNIQUE INDEX `User_email_key`(`email`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "Objective" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"description" TEXT,
"ownerId" INTEGER NOT NULL,
"quarter" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'NOT_STARTED',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Objective_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE `Objective` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`ownerId` INT NOT NULL,
`quarter` VARCHAR(191) NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'NOT_STARTED',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Objective_ownerId_idx`(`ownerId`),
INDEX `Objective_quarter_idx`(`quarter`),
INDEX `Objective_status_idx`(`status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "KeyResult" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"objectiveId" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0,
"startValue" INTEGER NOT NULL,
"targetValue" INTEGER NOT NULL,
"deadline" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "KeyResult_objectiveId_fkey" FOREIGN KEY ("objectiveId") REFERENCES "Objective" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE `KeyResult` (
`id` INT NOT NULL AUTO_INCREMENT,
`objectiveId` INT NOT NULL,
`title` VARCHAR(191) NOT NULL,
`progress` INT NOT NULL DEFAULT 0,
`startValue` INT NOT NULL,
`targetValue` INT NOT NULL,
`deadline` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `KeyResult_objectiveId_idx`(`objectiveId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "ProgressUpdate" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"keyResultId" INTEGER NOT NULL,
"progress" INTEGER NOT NULL,
"comment" TEXT,
"createdById" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProgressUpdate_keyResultId_fkey" FOREIGN KEY ("keyResultId") REFERENCES "KeyResult" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE `ProgressUpdate` (
`id` INT NOT NULL AUTO_INCREMENT,
`keyResultId` INT NOT NULL,
`progress` INT NOT NULL,
`comment` VARCHAR(191) NULL,
`createdById` INT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE INDEX "Objective_ownerId_idx" ON "Objective"("ownerId");
CREATE INDEX "Objective_quarter_idx" ON "Objective"("quarter");
CREATE INDEX "Objective_status_idx" ON "Objective"("status");
CREATE INDEX "KeyResult_objectiveId_idx" ON "KeyResult"("objectiveId");
ALTER TABLE `Objective` ADD CONSTRAINT `Objective_ownerId_fkey`
FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`)
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `KeyResult` ADD CONSTRAINT `KeyResult_objectiveId_fkey`
FOREIGN KEY (`objectiveId`) REFERENCES `Objective`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `ProgressUpdate` ADD CONSTRAINT `ProgressUpdate_keyResultId_fkey`
FOREIGN KEY (`keyResultId`) REFERENCES `KeyResult`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
@@ -3,7 +3,7 @@ generator client {
}
datasource db {
provider = "sqlite"
provider = "mysql"
url = env("DATABASE_URL")
}
@@ -0,0 +1,42 @@
version: '3.8'
# Production deployment for VPS: /opt/webapps/okr/
# DB credentials come from /opt/webapps/webapp-mysql.env (managed on VPS, not in repo).
# App secrets (JWT_SECRET) come from /opt/webapps/okr/.env.app (written by CI deploy step).
# Ports in use on this VPS — DO NOT conflict:
# 3000 = Gitea HTTP, 2222 = Gitea SSH, 8200 = Vault
services:
okr-backend:
image: okr-backend:latest
container_name: okr-backend
restart: unless-stopped
env_file:
- /opt/webapps/webapp-mysql.env # DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DATABASE_URL
- /opt/webapps/okr/.env.app # JWT_SECRET, FRONTEND_ORIGIN
environment:
PORT: "3001"
NODE_ENV: production
mem_limit: 256m
cpus: "0.5"
networks:
- okr-net
expose:
- "3001"
okr-frontend:
image: okr-frontend:latest
container_name: okr-frontend
restart: unless-stopped
ports:
- "80:80"
mem_limit: 64m
cpus: "0.25"
networks:
- okr-net
depends_on:
- okr-backend
networks:
okr-net:
driver: bridge
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Setup act_runner on the CI runner VPS (161.33.149.243).
#
# Prerequisites (run on CI runner VPS as ubuntu):
# 1. Get a runner registration token from Gitea:
# http://161.33.139.73:3000 → Site Administration → Runners → "Create Runner"
# 2. Generate a deploy SSH key for accessing the web VPS:
# ssh-keygen -t ed25519 -f /tmp/deploy_key -N ""
# ssh-copy-id -i /tmp/deploy_key.pub ubuntu@161.33.139.73
# Add /tmp/deploy_key (private) as Gitea secret: DEPLOY_SSH_KEY
# rm /tmp/deploy_key
#
# Usage:
# RUNNER_TOKEN=<token-from-gitea> bash setup-ci-runner.sh
#
set -euo pipefail
GITEA_URL="http://161.33.139.73:3000"
RUNNER_NAME="casan-ci-runner"
RUNNER_VERSION="v0.2.12"
INSTALL_DIR="/opt/act-runner"
if [[ -z "${RUNNER_TOKEN:-}" ]]; then
echo "ERROR: RUNNER_TOKEN env var is required."
echo " Get it from: $GITEA_URL → Site Administration → Runners → Create Runner"
exit 1
fi
echo "=== Installing act_runner $RUNNER_VERSION ==="
sudo mkdir -p "$INSTALL_DIR"
sudo curl -fsSL \
"https://gitea.com/gitea/act_runner/releases/download/${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64" \
-o "$INSTALL_DIR/act_runner"
sudo chmod +x "$INSTALL_DIR/act_runner"
echo "=== Writing runner config ==="
sudo tee "$INSTALL_DIR/config.yaml" > /dev/null <<'CONFIG'
log:
level: info
runner:
name: "casan-ci-runner"
capacity: 1
labels:
- "ci-runner:docker://catthehacker/ubuntu:act-22.04"
fetch_interval: 5s
fetch_timeout: 60s
container:
# host network so the job container can reach Gitea at 161.33.139.73:3000
network: host
# 2 GB RAM available on CI runner — builds need up to 1.5 GB
options: "--memory 1536m --cpus 1.5"
valid_volumes:
- "**"
CONFIG
echo "=== Registering runner with Gitea ==="
cd "$INSTALL_DIR"
sudo ./act_runner register \
--instance "$GITEA_URL" \
--token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" \
--no-interactive
echo "=== Installing systemd service ==="
sudo tee /etc/systemd/system/act-runner.service > /dev/null <<'SERVICE'
[Unit]
Description=Gitea act_runner (CI builds)
After=docker.service
Requires=docker.service
[Service]
User=ubuntu
Group=docker
WorkingDirectory=/opt/act-runner
ExecStart=/opt/act-runner/act_runner daemon --config /opt/act-runner/config.yaml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVICE
sudo systemctl daemon-reload
sudo systemctl enable act-runner
sudo systemctl start act-runner
echo ""
echo "=== CI runner setup complete ==="
echo "Check status: sudo systemctl status act-runner"
echo "View logs: sudo journalctl -u act-runner -f"
echo "Verify in Gitea: $GITEA_URL/-/admin/runners"