make deployment easier (#225)

This commit is contained in:
Jozef Dochan
2025-05-22 15:08:13 +02:00
committed by GitHub
parent d2934e8da4
commit 575c4cbedb
13 changed files with 318 additions and 261 deletions

74
Dockerfile Normal file
View File

@@ -0,0 +1,74 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install root dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# Install frontend dependencies
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci
# Install backend dependencies
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json* ./
RUN npm ci
# Build frontend
FROM base AS frontend-builder
WORKDIR /app
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/frontend/node_modules ./frontend/node_modules
COPY frontend ./frontend
WORKDIR /app/frontend
RUN npm run build
# Final production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8000
ENV FRONTEND_ORIGIN=http://localhost:8000
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy backend files
COPY backend ./backend
# Copy frontend build
COPY --from=frontend-builder /app/frontend/.next/standalone ./
COPY --from=frontend-builder /app/frontend/.next/static ./.next/static
COPY --from=frontend-builder /app/frontend/public ./public
# Copy Next.js module for the server
COPY --from=deps /app/frontend/node_modules/next ./node_modules/next
# Install backend dependencies directly in the container to ensure native modules are built correctly
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json* ./
RUN npm ci
WORKDIR /app
# Copy custom combined_server.js that combines frontend and backend
COPY combined_server.js ./
# Switch to non-root user
USER nextjs
# Expose the port
EXPOSE 8000
# Run database migrations and start the combined server
CMD ["node", "combined_server.js"]

View File

@@ -1,11 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE $PORT
RUN npx sequelize db:migrate
CMD ["node", "index.js"]

View File

@@ -9,6 +9,6 @@
}, },
"production": { "production": {
"dialect": "sqlite", "dialect": "sqlite",
"storage": "database/database.sqlite" "storage": "../database/database.sqlite"
} }
} }

View File

@@ -1,153 +1,7 @@
const express = require('express'); const server = require('./server');
const RateLimit = require('express-rate-limit');
const path = require('path');
const { Sequelize } = require('sequelize');
const app = express();
// enable frontend access
const cors = require('cors');
const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:8000';
const corsOptions = {
origin: frontendOrigin,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
};
app.use(cors(corsOptions));
// enable json middleware
app.use(express.json());
// enable rate limiter
const limiter = RateLimit({
windowMs: 60 * 60 * 1000, // 1h
max: 1000, // 1000 requests per hour
message: 'Too many requests from this IP, please try again after an hour',
});
app.use(limiter);
// Specify the directory to serve static files
app.use(express.static(path.join(__dirname, 'public')));
// init sequalize
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: 'database/database.sqlite',
logging: false,
});
// "/"
const indexRoute = require('./routes/index');
app.use('/', indexRoute);
// "users"
const usersIndexRoute = require('./routes/users/index')(sequelize);
const usersFindRoute = require('./routes/users/find')(sequelize);
const usersSearchRoute = require('./routes/users/search')(sequelize);
const signUpRoute = require('./routes/users/signup')(sequelize);
const signInRoute = require('./routes/users/signin')(sequelize);
app.use('/users', usersIndexRoute);
app.use('/users', usersFindRoute);
app.use('/users', usersSearchRoute);
app.use('/users', signUpRoute);
app.use('/users', signInRoute);
// ESM import
(async () => {
const updateRoute = await import('./routes/users/update.mjs');
app.use('/users', updateRoute.default(sequelize));
})();
// "/projects"
const projectsIndexRoute = require('./routes/projects/index')(sequelize);
const projectsShowRoute = require('./routes/projects/show')(sequelize);
const projectsNewRoute = require('./routes/projects/new')(sequelize);
const projectsEditRoute = require('./routes/projects/edit')(sequelize);
const projectsDeleteRoute = require('./routes/projects/delete')(sequelize);
app.use('/projects', projectsIndexRoute);
app.use('/projects', projectsShowRoute);
app.use('/projects', projectsNewRoute);
app.use('/projects', projectsEditRoute);
app.use('/projects', projectsDeleteRoute);
// "/folders"
const foldersIndexRoute = require('./routes/folders/index')(sequelize);
const foldersNewRoute = require('./routes/folders/new')(sequelize);
const foldersEditRoute = require('./routes/folders/edit')(sequelize);
const foldersDeleteRoute = require('./routes/folders/delete')(sequelize);
app.use('/folders', foldersIndexRoute);
app.use('/folders', foldersNewRoute);
app.use('/folders', foldersEditRoute);
app.use('/folders', foldersDeleteRoute);
// "/cases"
const casesDownloadRoute = require('./routes/cases/download')(sequelize);
const casesIndexRoute = require('./routes/cases/index')(sequelize);
const casesIndexByProjectIdRoute = require('./routes/cases/indexByProjectId')(sequelize);
const casesShowRoute = require('./routes/cases/show')(sequelize);
const casesNewRoute = require('./routes/cases/new')(sequelize);
const casesEditRoute = require('./routes/cases/edit')(sequelize);
const casesDeleteRoute = require('./routes/cases/delete')(sequelize);
app.use('/cases', casesDownloadRoute);
app.use('/cases', casesIndexRoute);
app.use('/cases', casesIndexByProjectIdRoute);
app.use('/cases', casesShowRoute);
app.use('/cases', casesNewRoute);
app.use('/cases', casesEditRoute);
app.use('/cases', casesDeleteRoute);
// "/steps"
const stepsEditRoute = require('./routes/steps/edit')(sequelize);
app.use('/steps', stepsEditRoute);
// "/attachments"
const attachmentsNewRoute = require('./routes/attachments/new')(sequelize);
const attachmentsDeleteRoute = require('./routes/attachments/delete')(sequelize);
const attachmentsDownloadRoute = require('./routes/attachments/download')(sequelize);
app.use('/attachments', attachmentsNewRoute);
app.use('/attachments', attachmentsDeleteRoute);
app.use('/attachments', attachmentsDownloadRoute);
// "/runs"
const runsDownloadRoute = require('./routes/runs/download')(sequelize);
const runsIndexRoute = require('./routes/runs/index')(sequelize);
const runsShowRoute = require('./routes/runs/show')(sequelize);
const runsNewRoute = require('./routes/runs/new')(sequelize);
const runsEditRoute = require('./routes/runs/edit')(sequelize);
const runDeleteRoute = require('./routes/runs/delete')(sequelize);
app.use('/runs', runsDownloadRoute);
app.use('/runs', runsIndexRoute);
app.use('/runs', runsShowRoute);
app.use('/runs', runsNewRoute);
app.use('/runs', runsEditRoute);
app.use('/runs', runDeleteRoute);
// "/runcases"
const runCaseIndexRoute = require('./routes/runcases/index')(sequelize);
const runCaseEditRoute = require('./routes/runcases/edit')(sequelize);
app.use('/runcases', runCaseIndexRoute);
app.use('/runcases', runCaseEditRoute);
// "/members"
const membersIndexRoute = require('./routes/members/index')(sequelize);
const membersNewRoute = require('./routes/members/new')(sequelize);
const membersEditRoute = require('./routes/members/edit')(sequelize);
const membersDeleteRoute = require('./routes/members/delete')(sequelize);
const membersCheckRoute = require('./routes/members/check')(sequelize);
app.use('/members', membersIndexRoute);
app.use('/members', membersNewRoute);
app.use('/members', membersEditRoute);
app.use('/members', membersDeleteRoute);
app.use('/members', membersCheckRoute);
// "/home"
const homeIndexRoute = require('./routes/home/index')(sequelize);
app.use('/home', homeIndexRoute);
const PORT = process.env.PORT || 8001; const PORT = process.env.PORT || 8001;
app.listen(PORT, () => {
server.listen(PORT, () => {
console.log(`Backend server is running on port ${PORT}`); console.log(`Backend server is running on port ${PORT}`);
console.log(`Access from the frontend origin: ${frontendOrigin} is valid.`);
if (!process.env.SECRET_KEY) {
console.log(
"[Warning]: Default key is used for token generation. Please set the environment variable 'SECRET_KEY'`."
);
}
}); });

View File

@@ -5,9 +5,9 @@
"scripts": { "scripts": {
"start": "node --env-file=.env index", "start": "node --env-file=.env index",
"dev": "node index", "dev": "node index",
"migrate": "bash scripts/migrate.sh", "migrate": "sequelize db:migrate",
"seed": "bash scripts/seed.sh", "seed": "sequelize db:seed:all",
"drop": "bash scripts/drop.sh" "drop": "sequelize db:migrate:undo:all"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",

View File

@@ -1,4 +0,0 @@
#!/bin/bash
npx sequelize db:migrate:undo:all
echo "Dropped all tables"

View File

@@ -1,4 +0,0 @@
#!/bin/bash
npx sequelize db:migrate
echo "Migration complete."

View File

@@ -1,3 +0,0 @@
#!/bin/bash
npx sequelize db:seed:all

143
backend/server.js Normal file
View File

@@ -0,0 +1,143 @@
const express = require('express');
const RateLimit = require('express-rate-limit');
const path = require('path');
const { Sequelize } = require('sequelize');
const app = express();
// enable json middleware
app.use(express.json());
// enable rate limiter
const limiter = RateLimit({
windowMs: 60 * 60 * 1000, // 1h
max: 1000, // 1000 requests per hour
message: 'Too many requests from this IP, please try again after an hour',
});
app.use(limiter);
// Specify the directory to serve static files
app.use(express.static(path.join(__dirname, 'public')));
// init sequalize
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: 'database/database.sqlite',
logging: false,
});
// "/"
const indexRoute = require('./routes/index');
app.use('/', indexRoute);
// "users"
const usersIndexRoute = require('./routes/users/index')(sequelize);
const usersFindRoute = require('./routes/users/find')(sequelize);
const usersSearchRoute = require('./routes/users/search')(sequelize);
const signUpRoute = require('./routes/users/signup')(sequelize);
const signInRoute = require('./routes/users/signin')(sequelize);
app.use('/users', usersIndexRoute);
app.use('/users', usersFindRoute);
app.use('/users', usersSearchRoute);
app.use('/users', signUpRoute);
app.use('/users', signInRoute);
// ESM import
(async () => {
const updateRoute = await import('./routes/users/update.mjs');
app.use('/users', updateRoute.default(sequelize));
})();
// "/projects"
const projectsIndexRoute = require('./routes/projects/index')(sequelize);
const projectsShowRoute = require('./routes/projects/show')(sequelize);
const projectsNewRoute = require('./routes/projects/new')(sequelize);
const projectsEditRoute = require('./routes/projects/edit')(sequelize);
const projectsDeleteRoute = require('./routes/projects/delete')(sequelize);
app.use('/projects', projectsIndexRoute);
app.use('/projects', projectsShowRoute);
app.use('/projects', projectsNewRoute);
app.use('/projects', projectsEditRoute);
app.use('/projects', projectsDeleteRoute);
// "/folders"
const foldersIndexRoute = require('./routes/folders/index')(sequelize);
const foldersNewRoute = require('./routes/folders/new')(sequelize);
const foldersEditRoute = require('./routes/folders/edit')(sequelize);
const foldersDeleteRoute = require('./routes/folders/delete')(sequelize);
app.use('/folders', foldersIndexRoute);
app.use('/folders', foldersNewRoute);
app.use('/folders', foldersEditRoute);
app.use('/folders', foldersDeleteRoute);
// "/cases"
// "/cases"
const casesDownloadRoute = require('./routes/cases/download')(sequelize);
const casesIndexRoute = require('./routes/cases/index')(sequelize);
const casesIndexByProjectIdRoute = require('./routes/cases/indexByProjectId')(sequelize);
const casesShowRoute = require('./routes/cases/show')(sequelize);
const casesNewRoute = require('./routes/cases/new')(sequelize);
const casesEditRoute = require('./routes/cases/edit')(sequelize);
const casesDeleteRoute = require('./routes/cases/delete')(sequelize);
app.use('/cases', casesDownloadRoute);
app.use('/cases', casesIndexRoute);
app.use('/cases', casesIndexByProjectIdRoute);
app.use('/cases', casesShowRoute);
app.use('/cases', casesNewRoute);
app.use('/cases', casesEditRoute);
app.use('/cases', casesDeleteRoute);
// "/steps"
const stepsEditRoute = require('./routes/steps/edit')(sequelize);
app.use('/steps', stepsEditRoute);
// "/attachments"
const attachmentsNewRoute = require('./routes/attachments/new')(sequelize);
const attachmentsDeleteRoute = require('./routes/attachments/delete')(sequelize);
const attachmentsDownloadRoute = require('./routes/attachments/download')(sequelize);
app.use('/attachments', attachmentsNewRoute);
app.use('/attachments', attachmentsDeleteRoute);
app.use('/attachments', attachmentsDownloadRoute);
// "/runs"
const runsDownloadRoute = require('./routes/runs/download')(sequelize);
const runsIndexRoute = require('./routes/runs/index')(sequelize);
const runsShowRoute = require('./routes/runs/show')(sequelize);
const runsNewRoute = require('./routes/runs/new')(sequelize);
const runsEditRoute = require('./routes/runs/edit')(sequelize);
const runDeleteRoute = require('./routes/runs/delete')(sequelize);
app.use('/runs', runsDownloadRoute);
app.use('/runs', runsIndexRoute);
app.use('/runs', runsShowRoute);
app.use('/runs', runsNewRoute);
app.use('/runs', runsEditRoute);
app.use('/runs', runDeleteRoute);
// "/runcases"
const runCaseIndexRoute = require('./routes/runcases/index')(sequelize);
const runCaseEditRoute = require('./routes/runcases/edit')(sequelize);
app.use('/runcases', runCaseIndexRoute);
app.use('/runcases', runCaseEditRoute);
// "/members"
const membersIndexRoute = require('./routes/members/index')(sequelize);
const membersNewRoute = require('./routes/members/new')(sequelize);
const membersEditRoute = require('./routes/members/edit')(sequelize);
const membersDeleteRoute = require('./routes/members/delete')(sequelize);
const membersCheckRoute = require('./routes/members/check')(sequelize);
app.use('/members', membersIndexRoute);
app.use('/members', membersNewRoute);
app.use('/members', membersEditRoute);
app.use('/members', membersDeleteRoute);
app.use('/members', membersCheckRoute);
// "/home"
const homeIndexRoute = require('./routes/home/index')(sequelize);
app.use('/home', homeIndexRoute);
if (!process.env.SECRET_KEY) {
console.log(
"[Warning]: Default key is used for token generation. Please set the environment variable 'SECRET_KEY'`."
);
}
// Export the app instead of starting the server
module.exports = app;

87
combined_server.js Normal file
View File

@@ -0,0 +1,87 @@
// Use express from backend node_modules
const express = require('./backend/node_modules/express');
const { createServer: createHttpServer } = require('http');
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
async function runMigrations() {
try {
console.log('Running database migrations...');
// Use execSync with cwd option to run in the backend directory
execSync('npx sequelize-cli db:migrate', {
cwd: path.join(__dirname, 'backend'),
stdio: 'inherit',
});
console.log('Database migrations completed successfully.');
if (process.env.IS_DEMO === 'true' || process.env.IS_DEMO === '1') {
console.log('Demo mode detected. Seeding the database...');
execSync('npx sequelize-cli db:seed:all', {
cwd: path.join(__dirname, 'backend'),
stdio: 'inherit',
});
console.log('Database seeding completed successfully.');
}
} catch (error) {
console.error('Error running database migrations or seeding:', error);
throw error;
}
}
function startServer() {
try {
const server = express();
const httpServer = createHttpServer(server);
// Import the backend app
const backendApp = require('./backend/server');
// Use the backend app for /api routes
server.use('/api', backendApp);
// For Next.js standalone build
// Check if we have the Next.js server file
const nextServerPath = './node_modules/next/dist/server/next.js';
if (fs.existsSync(nextServerPath)) {
// Import Next.js
const next = require(nextServerPath);
// Initialize Next.js app
const dev = process.env.NODE_ENV !== 'production';
const nextApp = next({ dev, dir: path.join(__dirname, '.') });
const handle = nextApp.getRequestHandler();
// Prepare Next.js
nextApp.prepare().then(() => {
console.log('nextjs prepared');
// Use Next.js to handle all other routes
server.all('*', (req, res) => {
return handle(req, res);
});
});
} else {
console.error('Next.js module not found at:', nextServerPath);
server.all('*', (req, res) => {
res.status(500).send('Frontend server not available');
});
}
const PORT = process.env.PORT || 8000;
httpServer.listen(PORT, (err) => {
if (err) throw err;
console.log(`> Ready on http://localhost:${PORT}`);
});
} catch (error) {
console.error('Error starting server:', error);
process.exit(1);
}
}
runMigrations().then(() => {
startServer();
}).catch(error => {
console.error('Failed to start application:', error);
process.exit(1);
});

View File

@@ -1,31 +1,16 @@
version: '3.8' version: '3.8'
services: services:
backend: unittcms:
image: unittcms-backend:latest image: unittcms:latest
build:
context: ./backend
ports:
- '8001:8001'
environment:
- NODE_ENV=production
- FRONTEND_ORIGIN=http://localhost:8000
- PORT=8001
volumes:
- db-data:/app/database
frontend:
image: unittcms-frontend:latest
build:
context: ./frontend
ports: ports:
- '8000:8000' - '8000:8000'
environment: environment:
- NODE_ENV=production
- NEXT_PUBLIC_BACKEND_ORIGIN=http://localhost:8001
- PORT=8000 - PORT=8000
depends_on: - SECRET_KEY=your_secret_key_here
- backend - IS_DEMO=false # set to true to seed the database
volumes:
- db-data:/app/database
volumes: volumes:
db-data: db-data:

View File

@@ -1,64 +0,0 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 8000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

View File

@@ -1,5 +1,5 @@
const Config = { const Config = {
apiServer: process.env.NEXT_PUBLIC_BACKEND_ORIGIN || 'http://localhost:8001', apiServer: process.env.NEXT_PUBLIC_BACKEND_ORIGIN || '/api',
// set 'NEXT_PUBLIC_IS_DEMO=true' in frontend/.env // set 'NEXT_PUBLIC_IS_DEMO=true' in frontend/.env
isDemoSite: process.env.NEXT_PUBLIC_IS_DEMO === 'true' || false, isDemoSite: process.env.NEXT_PUBLIC_IS_DEMO === 'true' || false,