hoàn thành bước tạo docker compose

This commit is contained in:
2026-03-31 14:47:29 +07:00
parent c362692c51
commit 5d5ec7fb1a
16 changed files with 427 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
.env
*.log
+19
View File
@@ -0,0 +1,19 @@
# Copy this file to .env and fill in the values before running docker-compose.
# NEVER commit .env to version control.
# ── PostgreSQL ────────────────────────────────────────────────────────────────
POSTGRES_DB=lms_db
POSTGRES_USER=lms_user
# REQUIRED — choose a strong password
POSTGRES_PASSWORD=change_me_strong_password
# ── JWT ───────────────────────────────────────────────────────────────────────
# REQUIRED — generate with: python -c "import secrets; print(secrets.token_hex(32))"
JWT_SECRET_KEY=change_me_generate_with_secrets_token_hex_32
# Token lifetime in minutes (default: 60)
ACCESS_TOKEN_EXPIRE_MINUTES=60
# ── Ports ─────────────────────────────────────────────────────────────────────
# Port exposed on the host for the Next.js frontend
FRONTEND_PORT=3000
+21
View File
@@ -0,0 +1,21 @@
# Environment
.env
.env.local
.env.*.local
# Python
__pycache__/
*.pyc
.venv/
venv/
# Node
node_modules/
.next/
# Uploads (runtime data)
/backend/uploads/
# OS
.DS_Store
Thumbs.db
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
.env
.env.*
!.env.example
.venv/
venv/
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
/uploads/
+28
View File
@@ -0,0 +1,28 @@
# ── Stage 1: deps ─────────────────────────────────────────────────────────────
FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── Stage 2: runtime ──────────────────────────────────────────────────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
# Copy installed packages from deps stage
COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=deps /usr/local/bin /usr/local/bin
# Copy application code
COPY . .
# Ensure entrypoint is executable
RUN chmod +x entrypoint.sh
# Upload directory (will be mounted as a Docker volume)
RUN mkdir -p /uploads
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]
+46
View File
@@ -0,0 +1,46 @@
# Alembic configuration — values prefixed with % are interpolated from env vars
# at runtime via env.py.
[alembic]
script_location = alembic
prepend_sys_path = .
# sqlalchemy.url is set dynamically in env.py from the DATABASE_URL env var.
# Do NOT put credentials here.
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+58
View File
@@ -0,0 +1,58 @@
"""Alembic environment — wires SQLAlchemy models into the migration engine."""
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Load app models so Alembic can inspect metadata
from app.database import Base # noqa: F401 — registers Base.metadata
import app.models # noqa: F401 — registers all ORM classes
# ── Alembic Config object ─────────────────────────────────────────────────────
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Inject DATABASE_URL from environment so it is never hard-coded
database_url = os.environ["DATABASE_URL"]
config.set_main_option("sqlalchemy.url", database_url)
target_metadata = Base.metadata
# ── Offline migrations (no live DB connection) ────────────────────────────────
def run_migrations_offline() -> None:
context.configure(
url=database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
# ── Online migrations (live DB connection) ────────────────────────────────────
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+17
View File
@@ -0,0 +1,17 @@
"""Alembic migration script template."""
# revision identifiers, used by Alembic.
revision = None
down_revision = None
branch_labels = None
depends_on = None
from alembic import op # noqa
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,102 @@
"""initial schema: users, pdfs, annotations
Revision ID: 0001
Revises:
Create Date: 2026-03-31
"""
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade() -> None:
# ── users ─────────────────────────────────────────────────────────────────
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(length=64), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_users_id", "users", ["id"])
op.create_index("ix_users_username", "users", ["username"], unique=True)
op.create_index("ix_users_email", "users", ["email"], unique=True)
# ── pdfs ──────────────────────────────────────────────────────────────────
op.create_table(
"pdfs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("file_path", sa.Text(), nullable=False),
sa.Column("total_pages", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_pdfs_id", "pdfs", ["id"])
op.create_index("ix_pdfs_user_id", "pdfs", ["user_id"])
# ── annotations ───────────────────────────────────────────────────────────
op.create_table(
"annotations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("pdf_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("page_number", sa.Integer(), nullable=False),
sa.Column(
"canvas_data",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["pdf_id"], ["pdfs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"pdf_id", "user_id", "page_number", name="uq_annotation_pdf_user_page"
),
)
op.create_index("ix_annotations_id", "annotations", ["id"])
op.create_index("ix_annotations_pdf_id", "annotations", ["pdf_id"])
op.create_index("ix_annotations_user_id", "annotations", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_annotations_user_id", table_name="annotations")
op.drop_index("ix_annotations_pdf_id", table_name="annotations")
op.drop_index("ix_annotations_id", table_name="annotations")
op.drop_table("annotations")
op.drop_index("ix_pdfs_user_id", table_name="pdfs")
op.drop_index("ix_pdfs_id", table_name="pdfs")
op.drop_table("pdfs")
op.drop_index("ix_users_email", table_name="users")
op.drop_index("ix_users_username", table_name="users")
op.drop_index("ix_users_id", table_name="users")
op.drop_table("users")
+2 -3
View File
@@ -3,14 +3,13 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine
from .routers import annotations, auth, pdfs
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create all tables on startup (use Alembic for migrations in production)
Base.metadata.create_all(bind=engine)
# Tables are managed by Alembic migrations.
# Run: alembic upgrade head (done by docker-compose entrypoint)
yield
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Docker entrypoint: wait for Postgres, run migrations, then start the app.
set -e
echo "⏳ Running Alembic migrations..."
alembic upgrade head
echo "🚀 Starting FastAPI..."
exec uvicorn main:app --host 0.0.0.0 --port 8000
+1
View File
@@ -6,3 +6,4 @@ passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
pydantic[email]==2.9.2
python-multipart==0.0.12
alembic==1.14.1
+69
View File
@@ -0,0 +1,69 @@
services:
# ── PostgreSQL ────────────────────────────────────────────────────────────────
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-lms_db}
POSTGRES_USER: ${POSTGRES_USER:-lms_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms_user} -d ${POSTGRES_DB:-lms_db}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- lms_net
# ── FastAPI backend ───────────────────────────────────────────────────────────
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-lms_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lms_db}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
JWT_ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
UPLOAD_DIR: /uploads
volumes:
- pdf_uploads:/uploads
expose:
- "8000"
networks:
- lms_net
# ── Next.js frontend ──────────────────────────────────────────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://backend:8000
restart: unless-stopped
depends_on:
- backend
environment:
NEXT_PUBLIC_API_URL: http://backend:8000
NODE_ENV: production
ports:
- "${FRONTEND_PORT:-3000}:3000"
networks:
- lms_net
# ── Volumes ───────────────────────────────────────────────────────────────────
volumes:
postgres_data:
pdf_uploads:
# ── Networks ──────────────────────────────────────────────────────────────────
networks:
lms_net:
driver: bridge
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.next/
.env
.env.*
!.env.example
*.log
+33
View File
@@ -0,0 +1,33 @@
# ── Stage 1: deps ─────────────────────────────────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# ── Stage 2: build ────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_API_URL is baked in at build time via docker-compose build args
ARG NEXT_PUBLIC_API_URL=http://backend:8000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# ── Stage 3: runtime ──────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone", // enables minimal Docker image via .next/standalone
async rewrites() {
return [
{