فهرست منبع

docker scripts

guoyujing 3 ماه پیش
والد
کامیت
92e5cdf530

+ 14 - 0
.dockerignore

@@ -0,0 +1,14 @@
+.git
+.gitignore
+.DS_Store
+.idea
+.vscode
+.worktrees
+node_modules
+frontend/node_modules
+backend/node_modules
+frontend/dist
+backend/dist
+coverage
+*.log
+docker/.env

+ 8 - 0
backend/prisma/seed-demo.cjs

@@ -0,0 +1,8 @@
+async function main() {
+  console.log('Demo seed skipped.');
+}
+
+main().catch((error) => {
+  console.error(error);
+  process.exitCode = 1;
+});

+ 72 - 0
backend/prisma/seed-initial.cjs

@@ -0,0 +1,72 @@
+const { PrismaClient } = require('@prisma/client');
+const { hash } = require('bcryptjs');
+
+function getInitialStylistFromEnv(env = process.env) {
+  return {
+    id: BigInt(env.INITIAL_STYLIST_ID || '1'),
+    username: env.INITIAL_STYLIST_USERNAME || 'Ogawa',
+    email: env.INITIAL_STYLIST_EMAIL || 'stylist@example.com',
+    password: env.INITIAL_STYLIST_PASSWORD || 'Passw0rd!',
+  };
+}
+
+async function seedInitialStylist(prisma, initialStylist, hashPassword) {
+  const existing = await prisma.hairstylistUser.findUnique({
+    where: { id: initialStylist.id },
+  });
+
+  if (existing) {
+    return 'existing';
+  }
+
+  const password = await hashPassword(initialStylist.password, 10);
+  await prisma.hairstylistUser.create({
+    data: {
+      id: initialStylist.id,
+      username: initialStylist.username,
+      email: initialStylist.email,
+      password,
+      isDeleted: 0,
+    },
+  });
+  return 'created';
+}
+
+async function seedInitialData(prisma, options = {}) {
+  const env = options.env || process.env;
+  const hashPassword = options.hashPassword || hash;
+  const initialStylist = getInitialStylistFromEnv(env);
+
+  const stylist = await seedInitialStylist(prisma, initialStylist, hashPassword);
+
+  return {
+    stylist,
+    stylistUsername: initialStylist.username,
+    stylistEmail: initialStylist.email,
+  };
+}
+
+async function main() {
+  const prisma = new PrismaClient();
+
+  try {
+    const result = await seedInitialData(prisma);
+    console.log(
+      `Initial account ensured: stylist=${result.stylist}, account=${result.stylistUsername} <${result.stylistEmail}>`,
+    );
+  } finally {
+    await prisma.$disconnect();
+  }
+}
+
+if (require.main === module) {
+  main().catch((error) => {
+    console.error(error);
+    process.exitCode = 1;
+  });
+}
+
+module.exports = {
+  getInitialStylistFromEnv,
+  seedInitialData,
+};

+ 75 - 0
backend/prisma/seed-initial.spec.ts

@@ -0,0 +1,75 @@
+const {
+  getInitialStylistFromEnv,
+  seedInitialData,
+} = require('./seed-initial.cjs');
+
+describe('seedInitialData', () => {
+  const env = {
+    INITIAL_STYLIST_ID: '1',
+    INITIAL_STYLIST_USERNAME: 'Ogawa',
+    INITIAL_STYLIST_EMAIL: 'stylist@example.com',
+    INITIAL_STYLIST_PASSWORD: 'Passw0rd!',
+  };
+
+  const createPrismaMock = () => ({
+    hairstylistUser: {
+      findUnique: jest.fn(),
+      create: jest.fn(),
+      update: jest.fn(),
+    },
+    systemConfig: {
+      upsert: jest.fn(),
+    },
+    hairTemplate: {
+      upsert: jest.fn(),
+    },
+  });
+
+  it('seeds only the initial stylist account', async () => {
+    const prisma = createPrismaMock();
+    prisma.hairstylistUser.findUnique.mockResolvedValue(null);
+    const hashPassword = jest.fn().mockResolvedValue('hashed-password');
+
+    const result = await seedInitialData(prisma, { env, hashPassword });
+
+    expect(hashPassword).toHaveBeenCalledWith('Passw0rd!', 10);
+    expect(prisma.hairstylistUser.create).toHaveBeenCalledWith({
+      data: {
+        id: BigInt(1),
+        username: 'Ogawa',
+        email: 'stylist@example.com',
+        password: 'hashed-password',
+        isDeleted: 0,
+      },
+    });
+    expect(prisma.systemConfig.upsert).not.toHaveBeenCalled();
+    expect(prisma.hairTemplate.upsert).not.toHaveBeenCalled();
+    expect(result).toEqual({
+      stylist: 'created',
+      stylistUsername: 'Ogawa',
+      stylistEmail: 'stylist@example.com',
+    });
+  });
+
+  it('does not reset or rewrite the initial stylist when it already exists', async () => {
+    const prisma = createPrismaMock();
+    prisma.hairstylistUser.findUnique.mockResolvedValue({ id: BigInt(1) });
+    const hashPassword = jest.fn();
+
+    const result = await seedInitialData(prisma, { env, hashPassword });
+
+    expect(hashPassword).not.toHaveBeenCalled();
+    expect(prisma.hairstylistUser.create).not.toHaveBeenCalled();
+    expect(prisma.hairstylistUser.update).not.toHaveBeenCalled();
+    expect(result.stylist).toBe('existing');
+  });
+
+  it('keeps the expected default account', () => {
+    expect(getInitialStylistFromEnv(env)).toEqual({
+      id: BigInt(1),
+      username: 'Ogawa',
+      email: 'stylist@example.com',
+      password: 'Passw0rd!',
+    });
+  });
+});

+ 32 - 0
docker/.env.example

@@ -0,0 +1,32 @@
+FRONTEND_PORT=8080
+APP_BASE_URL=http://localhost:8080
+CORS_ORIGIN=http://localhost:8080
+
+MYSQL_ROOT_PASSWORD=123456
+MYSQL_DATABASE=aitori_stylist
+REDIS_PASSWORD=123456
+
+JWT_SECRET=replace-me
+JWT_EXPIRES_IN=7d
+
+INITIAL_STYLIST_ID=1
+INITIAL_STYLIST_USERNAME=Ogawa
+INITIAL_STYLIST_EMAIL=stylist@example.com
+INITIAL_STYLIST_PASSWORD=Passw0rd!
+
+STORAGE_DRIVER=gcs
+GCS_BUCKET=aitori-public
+GCS_PROJECT_ID=app-aitori-jp
+GCS_CLIENT_EMAIL=oss-227@app-aitori-jp.iam.gserviceaccount.com
+GCS_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n
+CUSTOMER_DEFAULT_AVATAR_URL=https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png
+
+AI_SIMULATION_BASE_URL=http://host.docker.internal:8084
+AI_SIMULATION_REFRESH_INTERVAL_MS=10000
+AI_SIMULATION_HTTP_TIMEOUT_MS=15000
+
+SEED_DEMO_DATA=true
+
+# Optional: override when Docker Hub is not reachable from your network.
+NODE_IMAGE=node:20-alpine
+NGINX_IMAGE=nginx:1.27-alpine

+ 104 - 0
docker/README.md

@@ -0,0 +1,104 @@
+# Docker Compose
+
+Production-style local stack for Aitori Stylist.
+
+## Run
+
+```bash
+cd docker
+cp .env.example .env
+docker compose up --build
+```
+
+Open:
+
+- Frontend: http://127.0.0.1:8080/
+- API health through Nginx: http://127.0.0.1:8080/api/v1/health
+
+If port `8080` is already in use:
+
+```bash
+FRONTEND_PORT=8081 docker compose up --build
+```
+
+Default initial login:
+
+- Email: `stylist@example.com`
+- Password: `Passw0rd!`
+
+## Notes
+
+- MySQL and Redis are only exposed inside the compose network.
+- Backend runs `prisma migrate deploy` before startup.
+- The initial login account is seeded idempotently after migrations.
+- Demo seed currently does not insert any data and can be skipped with
+  `SEED_DEMO_DATA=false`.
+- If Docker Hub is slow or blocked, edit `NODE_IMAGE` and `NGINX_IMAGE` in `.env`
+  to point at your registry mirror.
+
+## Build And Push Images
+
+Login to GHCR first:
+
+```bash
+echo <GHCR_TOKEN> | docker login ghcr.io -u gyj2032-byte --password-stdin
+```
+
+Build Linux images and push them to `ghcr.io/gyj2032-byte`:
+
+```bash
+./docker_build.sh
+./docker_build.sh v1.0.0
+```
+
+Defaults:
+
+- Registry: `ghcr.io/gyj2032-byte`
+- Images: `stylist-backend`, `stylist-frontend`
+- Platform: `linux/amd64`
+- Tag: current git short SHA, plus `latest`
+
+Override examples:
+
+```bash
+PLATFORM=linux/arm64 ./docker_build.sh v1.0.0
+REGISTRY=ghcr.io/gyj2032-byte IMAGE_PREFIX=stylist ./docker_build.sh
+```
+
+## Production Compose
+
+Run the published GHCR images without building from source:
+
+```bash
+cd docker
+cp .env.prod.example .env
+IMAGE_TAG=latest docker compose -f docker-compose.prod.yml pull
+IMAGE_TAG=latest docker compose -f docker-compose.prod.yml up -d
+```
+
+Use a specific pushed tag:
+
+```bash
+IMAGE_TAG=v1.0.0 docker compose -f docker-compose.prod.yml up -d
+```
+
+## HTTPS Nginx
+
+Keep TLS on the host Nginx and proxy to the frontend container port:
+
+```bash
+sudo cp nginx.conf /etc/nginx/conf.d/stylist.agigo.cloud.conf
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+If `FRONTEND_PORT` is not `8080`, update `proxy_pass` in
+`nginx.https.example.conf` to match it.
+
+## Reset Data
+
+```bash
+cd docker
+docker compose down -v
+docker compose up --build
+```

+ 12 - 0
docker/backend-entrypoint.sh

@@ -0,0 +1,12 @@
+#!/bin/sh
+set -eu
+
+npx prisma migrate deploy
+
+node /app/prisma/seed-initial.cjs
+
+if [ "${SEED_DEMO_DATA:-true}" = "true" ]; then
+  node /app/prisma/seed-demo.cjs
+fi
+
+exec "$@"

+ 35 - 0
docker/backend.Dockerfile

@@ -0,0 +1,35 @@
+ARG NODE_IMAGE=node:20-alpine
+FROM ${NODE_IMAGE} AS build
+
+WORKDIR /app
+
+COPY backend/package*.json ./
+RUN npm ci
+
+COPY backend/prisma ./prisma
+RUN npm run prisma:generate
+
+COPY backend/ ./
+RUN npm run build
+
+FROM ${NODE_IMAGE} AS runtime
+
+WORKDIR /app
+ENV NODE_ENV=production
+
+COPY backend/package*.json ./
+RUN npm ci --include=dev
+
+COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
+COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client
+COPY --from=build /app/prisma ./prisma
+COPY backend/prisma/seed-initial.cjs ./prisma/seed-initial.cjs
+COPY backend/prisma/seed-demo.cjs ./prisma/seed-demo.cjs
+COPY --from=build /app/dist ./dist
+COPY docker/backend-entrypoint.sh /usr/local/bin/backend-entrypoint.sh
+
+RUN chmod +x /usr/local/bin/backend-entrypoint.sh
+
+EXPOSE 3000
+ENTRYPOINT ["backend-entrypoint.sh"]
+CMD ["npm", "run", "start:prod"]

+ 83 - 0
docker/docker-compose.prod.yml

@@ -0,0 +1,83 @@
+name: stylist
+
+services:
+  mysql:
+    image: mysql:8.0
+    command: --default-authentication-plugin=mysql_native_password
+    restart: unless-stopped
+    environment:
+      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456}
+      MYSQL_DATABASE: ${MYSQL_DATABASE:-aitori_stylist}
+    volumes:
+      - mysql-data:/var/lib/mysql
+    healthcheck:
+      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
+      interval: 5s
+      timeout: 3s
+      retries: 30
+
+  redis:
+    image: redis:7-alpine
+    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-123456}"]
+    restart: unless-stopped
+    environment:
+      REDIS_PASSWORD: ${REDIS_PASSWORD:-123456}
+    volumes:
+      - redis-data:/data
+    healthcheck:
+      test: ["CMD-SHELL", "redis-cli -a $${REDIS_PASSWORD:-123456} ping | grep PONG"]
+      interval: 5s
+      timeout: 3s
+      retries: 30
+
+  backend:
+    image: ${REGISTRY:-ghcr.io/gyj2032-byte}/${IMAGE_PREFIX:-stylist}-backend:${IMAGE_TAG:-latest}
+    restart: unless-stopped
+    environment:
+      PORT: 3000
+      APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080}
+      DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-123456}@mysql:3306/${MYSQL_DATABASE:-aitori_stylist}
+      REDIS_URL: redis://:${REDIS_PASSWORD:-123456}@redis:6379
+      JWT_SECRET: ${JWT_SECRET:-replace-me}
+      JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
+      INITIAL_STYLIST_ID: ${INITIAL_STYLIST_ID:-1}
+      INITIAL_STYLIST_USERNAME: ${INITIAL_STYLIST_USERNAME:-Ogawa}
+      INITIAL_STYLIST_EMAIL: ${INITIAL_STYLIST_EMAIL:-stylist@example.com}
+      INITIAL_STYLIST_PASSWORD: ${INITIAL_STYLIST_PASSWORD:-Passw0rd!}
+      CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:8080}
+      NODE_ENV: production
+      STORAGE_DRIVER: ${STORAGE_DRIVER:-gcs}
+      GCS_BUCKET: ${GCS_BUCKET:-aitori-public}
+      GCS_PROJECT_ID: ${GCS_PROJECT_ID:-app-aitori-jp}
+      GCS_CLIENT_EMAIL: ${GCS_CLIENT_EMAIL:-oss-227@app-aitori-jp.iam.gserviceaccount.com}
+      GCS_PRIVATE_KEY: ${GCS_PRIVATE_KEY:------BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n}
+      CUSTOMER_DEFAULT_AVATAR_URL: ${CUSTOMER_DEFAULT_AVATAR_URL:-https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png}
+      AI_SIMULATION_BASE_URL: ${AI_SIMULATION_BASE_URL:-http://host.docker.internal:8084}
+      AI_SIMULATION_REFRESH_INTERVAL_MS: ${AI_SIMULATION_REFRESH_INTERVAL_MS:-10000}
+      AI_SIMULATION_HTTP_TIMEOUT_MS: ${AI_SIMULATION_HTTP_TIMEOUT_MS:-15000}
+      SEED_DEMO_DATA: ${SEED_DEMO_DATA:-false}
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    depends_on:
+      mysql:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/v1/health >/dev/null"]
+      interval: 10s
+      timeout: 5s
+      retries: 30
+
+  frontend:
+    image: ${REGISTRY:-ghcr.io/gyj2032-byte}/${IMAGE_PREFIX:-stylist}-frontend:${IMAGE_TAG:-latest}
+    restart: unless-stopped
+    ports:
+      - "${FRONTEND_PORT:-8080}:80"
+    depends_on:
+      backend:
+        condition: service_healthy
+
+volumes:
+  mysql-data:
+  redis-data:

+ 88 - 0
docker/docker-compose.yml

@@ -0,0 +1,88 @@
+name: stylist
+
+services:
+  mysql:
+    image: mysql:8.0
+    command: --default-authentication-plugin=mysql_native_password
+    environment:
+      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456}
+      MYSQL_DATABASE: ${MYSQL_DATABASE:-aitori_stylist}
+    volumes:
+      - mysql-data:/var/lib/mysql
+    healthcheck:
+      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
+      interval: 5s
+      timeout: 3s
+      retries: 30
+
+  redis:
+    image: redis:7-alpine
+    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-123456}"]
+    environment:
+      REDIS_PASSWORD: ${REDIS_PASSWORD:-123456}
+    volumes:
+      - redis-data:/data
+    healthcheck:
+      test: ["CMD-SHELL", "redis-cli -a $${REDIS_PASSWORD:-123456} ping | grep PONG"]
+      interval: 5s
+      timeout: 3s
+      retries: 30
+
+  backend:
+    build:
+      context: ..
+      dockerfile: docker/backend.Dockerfile
+      args:
+        NODE_IMAGE: ${NODE_IMAGE:-node:20-alpine}
+    environment:
+      PORT: 3000
+      APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080}
+      DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-123456}@mysql:3306/${MYSQL_DATABASE:-aitori_stylist}
+      REDIS_URL: redis://:${REDIS_PASSWORD:-123456}@redis:6379
+      JWT_SECRET: ${JWT_SECRET:-replace-me}
+      JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
+      INITIAL_STYLIST_ID: ${INITIAL_STYLIST_ID:-1}
+      INITIAL_STYLIST_USERNAME: ${INITIAL_STYLIST_USERNAME:-Ogawa}
+      INITIAL_STYLIST_EMAIL: ${INITIAL_STYLIST_EMAIL:-stylist@example.com}
+      INITIAL_STYLIST_PASSWORD: ${INITIAL_STYLIST_PASSWORD:-Passw0rd!}
+      CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:8080}
+      NODE_ENV: production
+      STORAGE_DRIVER: ${STORAGE_DRIVER:-gcs}
+      GCS_BUCKET: ${GCS_BUCKET:-aitori-public}
+      GCS_PROJECT_ID: ${GCS_PROJECT_ID:-app-aitori-jp}
+      GCS_CLIENT_EMAIL: ${GCS_CLIENT_EMAIL:-oss-227@app-aitori-jp.iam.gserviceaccount.com}
+      GCS_PRIVATE_KEY: ${GCS_PRIVATE_KEY:------BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n}
+      CUSTOMER_DEFAULT_AVATAR_URL: ${CUSTOMER_DEFAULT_AVATAR_URL:-https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png}
+      AI_SIMULATION_BASE_URL: ${AI_SIMULATION_BASE_URL:-http://host.docker.internal:8084}
+      AI_SIMULATION_REFRESH_INTERVAL_MS: ${AI_SIMULATION_REFRESH_INTERVAL_MS:-10000}
+      AI_SIMULATION_HTTP_TIMEOUT_MS: ${AI_SIMULATION_HTTP_TIMEOUT_MS:-15000}
+      SEED_DEMO_DATA: ${SEED_DEMO_DATA:-true}
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    depends_on:
+      mysql:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/v1/health >/dev/null"]
+      interval: 10s
+      timeout: 5s
+      retries: 30
+
+  frontend:
+    build:
+      context: ..
+      dockerfile: docker/frontend.Dockerfile
+      args:
+        NODE_IMAGE: ${NODE_IMAGE:-node:20-alpine}
+        NGINX_IMAGE: ${NGINX_IMAGE:-nginx:1.27-alpine}
+    ports:
+      - "${FRONTEND_PORT:-8080}:80"
+    depends_on:
+      backend:
+        condition: service_healthy
+
+volumes:
+  mysql-data:
+  redis-data:

+ 76 - 0
docker/docker_build.sh

@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+REGISTRY="${REGISTRY:-ghcr.io/gyj2032-byte}"
+IMAGE_PREFIX="${IMAGE_PREFIX:-stylist}"
+PLATFORM="${PLATFORM:-linux/amd64}"
+NODE_IMAGE="${NODE_IMAGE:-node:20-alpine}"
+NGINX_IMAGE="${NGINX_IMAGE:-nginx:1.27-alpine}"
+
+if [ "$#" -gt 1 ]; then
+  echo "Usage: $0 [tag]" >&2
+  exit 2
+fi
+
+if [ "$#" -eq 1 ]; then
+  TAG="$1"
+else
+  TAG="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || date +%Y%m%d%H%M%S)"
+fi
+
+BACKEND_IMAGE="$REGISTRY/${IMAGE_PREFIX}-backend"
+FRONTEND_IMAGE="$REGISTRY/${IMAGE_PREFIX}-frontend"
+
+if ! command -v docker >/dev/null 2>&1; then
+  echo "docker is required but was not found in PATH." >&2
+  exit 1
+fi
+
+if ! docker buildx version >/dev/null 2>&1; then
+  echo "docker buildx is required but is not available." >&2
+  exit 1
+fi
+
+echo "Building and pushing Linux images:"
+echo "  $BACKEND_IMAGE:$TAG"
+echo "  $BACKEND_IMAGE:latest"
+echo "  $FRONTEND_IMAGE:$TAG"
+echo "  $FRONTEND_IMAGE:latest"
+echo "Platform: $PLATFORM"
+
+buildx_build() {
+  if [ -n "${BUILDER:-}" ]; then
+    docker buildx build --builder "$BUILDER" "$@"
+  else
+    docker buildx build "$@"
+  fi
+}
+
+buildx_build \
+  --platform "$PLATFORM" \
+  --build-arg NODE_IMAGE="$NODE_IMAGE" \
+  -f "$ROOT_DIR/docker/backend.Dockerfile" \
+  -t "$BACKEND_IMAGE:$TAG" \
+  -t "$BACKEND_IMAGE:latest" \
+  --push "$ROOT_DIR"
+
+buildx_build \
+  --platform "$PLATFORM" \
+  --build-arg NODE_IMAGE="$NODE_IMAGE" \
+  --build-arg NGINX_IMAGE="$NGINX_IMAGE" \
+  -f "$ROOT_DIR/docker/frontend.Dockerfile" \
+  -t "$FRONTEND_IMAGE:$TAG" \
+  -t "$FRONTEND_IMAGE:latest" \
+  --push "$ROOT_DIR"
+
+cat <<EOF
+
+Pushed:
+  $BACKEND_IMAGE:$TAG
+  $BACKEND_IMAGE:latest
+  $FRONTEND_IMAGE:$TAG
+  $FRONTEND_IMAGE:latest
+EOF

+ 19 - 0
docker/frontend.Dockerfile

@@ -0,0 +1,19 @@
+ARG NODE_IMAGE=node:20-alpine
+ARG NGINX_IMAGE=nginx:1.27-alpine
+
+FROM ${NODE_IMAGE} AS build
+
+WORKDIR /app
+
+COPY frontend/package*.json ./
+RUN npm ci
+
+COPY frontend/ ./
+RUN npm run build
+
+FROM ${NGINX_IMAGE} AS runtime
+
+COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+
+EXPOSE 80

+ 31 - 0
docker/nginx-https.conf

@@ -0,0 +1,31 @@
+server {
+    listen 80;
+    server_name stylist.agigo.cloud;
+
+    return 301 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name stylist.agigo.cloud;
+
+    ssl_certificate cert/agigo.cloud.pem;
+    ssl_certificate_key cert/agigo.cloud.key;
+
+    client_max_body_size 100m;
+
+    location / {
+        proxy_pass http://127.0.0.1:8100/;
+        proxy_http_version 1.1;
+
+        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 https;
+        proxy_set_header X-Forwarded-Host $host;
+
+        proxy_connect_timeout 60s;
+        proxy_send_timeout 300s;
+        proxy_read_timeout 300s;
+    }
+}

+ 22 - 0
docker/nginx.conf

@@ -0,0 +1,22 @@
+server {
+    listen 80;
+    server_name _;
+
+    client_max_body_size 100m;
+
+    root /usr/share/nginx/html;
+    index index.html;
+
+    location /api/ {
+        proxy_pass http://backend:3000;
+        proxy_http_version 1.1;
+        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;
+    }
+
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+}