diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..383f522 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +apps/*/node_modules +apps/*/dist +apps/*/coverage +coverage +.git +.gitignore +.dockerignore +*.log +.env +.env.* +!.env.example diff --git a/.gitignore b/.gitignore index b29698c..3762979 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ apps/*/node_modules/ apps/*/dist/ apps/*/coverage/ apps/web/.tanstack/ + +deploy/lightsail/.env +deploy/lightsail/api.env diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..c4ccff9 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json + +RUN npm ci + +COPY apps/api apps/api + +RUN npm run build:api +RUN npm prune --omit=dev + +FROM node:20-alpine AS runtime + +ENV NODE_ENV=production +ENV PORT=8080 + +WORKDIR /app + +COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./ +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/apps/api/package.json ./apps/api/package.json +COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist + +RUN chown -R node:node /app + +USER node + +EXPOSE 8080 + +CMD ["node", "apps/api/dist/server.js"] diff --git a/apps/api/src/config/_tests_/database-startup.test.ts b/apps/api/src/config/_tests_/database-startup.test.ts new file mode 100644 index 0000000..80aa96e --- /dev/null +++ b/apps/api/src/config/_tests_/database-startup.test.ts @@ -0,0 +1,37 @@ +import { getDatabaseSyncOptions, syncDatabaseForStartup } from "../database-startup"; + +describe("database startup sync behavior", () => { + it("skips Sequelize sync in production by default", async () => { + const sequelize = { sync: jest.fn() }; + + await syncDatabaseForStartup(sequelize, { + NODE_ENV: "production", + }); + + expect(sequelize.sync).not.toHaveBeenCalled(); + }); + + it("keeps non-production startup sync enabled by default", async () => { + const sequelize = { sync: jest.fn().mockResolvedValue(undefined) }; + + await syncDatabaseForStartup(sequelize, { + NODE_ENV: "development", + }); + + expect(sequelize.sync).toHaveBeenCalledWith(); + }); + + it("allows explicit production sync when requested", () => { + expect(getDatabaseSyncOptions({ + NODE_ENV: "production", + DB_SYNC: "true", + })).toEqual({}); + }); + + it("preserves explicit alter sync mode", () => { + expect(getDatabaseSyncOptions({ + NODE_ENV: "development", + DB_SYNC_ALTER: "true", + })).toEqual({ alter: true }); + }); +}); diff --git a/apps/api/src/config/database-startup.ts b/apps/api/src/config/database-startup.ts new file mode 100644 index 0000000..1114f19 --- /dev/null +++ b/apps/api/src/config/database-startup.ts @@ -0,0 +1,38 @@ +import type { Sequelize } from "sequelize"; + +type SyncOptions = Parameters[0]; +type StartupEnv = Partial>; + +export function getDatabaseSyncOptions(env: StartupEnv = process.env): SyncOptions | undefined | null { + if (env.DB_SYNC_ALTER === "true") { + return { alter: true }; + } + + if (env.DB_SYNC === "true") { + return {}; + } + + if (env.NODE_ENV === "production" && env.DB_SYNC !== "true") { + return null; + } + + return undefined; +} + +export async function syncDatabaseForStartup( + sequelize: Pick, + env: StartupEnv = process.env +) { + const syncOptions = getDatabaseSyncOptions(env); + + if (syncOptions === null) { + return; + } + + if (syncOptions === undefined) { + await sequelize.sync(); + return; + } + + await sequelize.sync(syncOptions); +} diff --git a/apps/api/src/routes/_tests_/health.routes.test.ts b/apps/api/src/routes/_tests_/health.routes.test.ts new file mode 100644 index 0000000..4b5e3ac --- /dev/null +++ b/apps/api/src/routes/_tests_/health.routes.test.ts @@ -0,0 +1,19 @@ +import express from "express"; +import request from "supertest"; + +import healthRoutes from "../health.routes"; + +describe("Health Routes", () => { + const app = express(); + + beforeAll(() => { + app.use(healthRoutes); + }); + + it("returns a simple healthy response for load balancers", async () => { + const response = await request(app).get("/healthz"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: "ok" }); + }); +}); diff --git a/apps/api/src/routes/health.routes.ts b/apps/api/src/routes/health.routes.ts new file mode 100644 index 0000000..df6703e --- /dev/null +++ b/apps/api/src/routes/health.routes.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; + +const router = Router(); + +router.get("/healthz", (_req, res) => { + res.status(200).json({ status: "ok" }); +}); + +export default router; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 7a9a8c0..465b004 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -16,6 +16,8 @@ import emailRoutes from "./routes/email.routes"; import checkInRoutes from "./routes/check-in.routes"; import cookieParser from "cookie-parser"; import { getCorsOrigins } from "./config/app"; +import { syncDatabaseForStartup } from "./config/database-startup"; +import healthRoutes from "./routes/health.routes"; const port = Number(process.env.PORT || 8080); const allowedOrigins = new Set(getCorsOrigins()); @@ -35,6 +37,8 @@ app.use(cors({ credentials: true, })) +app.use(healthRoutes); + // Register the RestResponse middleware before all routes app.use(restResponse); @@ -60,11 +64,7 @@ async function start() { await sequelize.authenticate(); console.log("Database connected"); - if (process.env.DB_SYNC_ALTER === "true") { - await sequelize.sync({ alter: true }); - } else { - await sequelize.sync(); - } + await syncDatabaseForStartup(sequelize); // console.log(sequelize.models); app.listen(port, () => { diff --git a/deploy/lightsail/.env.example b/deploy/lightsail/.env.example new file mode 100644 index 0000000..3921d49 --- /dev/null +++ b/deploy/lightsail/.env.example @@ -0,0 +1,3 @@ +APP_DOMAIN=example.com +WEB_ROOT=/srv/nail-star/web +API_IMAGE=nail-star-api:latest diff --git a/deploy/lightsail/Caddyfile b/deploy/lightsail/Caddyfile new file mode 100644 index 0000000..6490a6a --- /dev/null +++ b/deploy/lightsail/Caddyfile @@ -0,0 +1,16 @@ +{$APP_DOMAIN} { + encode zstd gzip + + handle /api/* { + uri strip_prefix /api + reverse_proxy api:8080 + } + + handle /healthz { + reverse_proxy api:8080 + } + + root * /srv/nail-star/web + try_files {path} /index.html + file_server +} diff --git a/deploy/lightsail/api.env.example b/deploy/lightsail/api.env.example new file mode 100644 index 0000000..7f8623d --- /dev/null +++ b/deploy/lightsail/api.env.example @@ -0,0 +1,11 @@ +DB_HOST=your-lightsail-postgres-endpoint +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=replace-me +DB_NAME=nail_star +JWT_SECRET=replace-me-with-a-long-random-secret +CORS_ORIGINS=https://example.com +COOKIE_SECURE=true +COOKIE_SAME_SITE=lax +MAIL_USER=your-ses-smtp-user +MAIL_PASS=your-ses-smtp-password diff --git a/deploy/lightsail/compose.yml b/deploy/lightsail/compose.yml new file mode 100644 index 0000000..41b432c --- /dev/null +++ b/deploy/lightsail/compose.yml @@ -0,0 +1,41 @@ +services: + api: + build: + context: ../.. + dockerfile: apps/api/Dockerfile + image: ${API_IMAGE:-nail-star-api:latest} + restart: unless-stopped + env_file: + - ./api.env + environment: + NODE_ENV: production + PORT: 8080 + expose: + - "8080" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + api: + condition: service_healthy + ports: + - "80:80" + - "443:443" + environment: + APP_DOMAIN: ${APP_DOMAIN} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ${WEB_ROOT:-/srv/nail-star/web}:/srv/nail-star/web:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docs/deploy-lightsail.md b/docs/deploy-lightsail.md new file mode 100644 index 0000000..aef5764 --- /dev/null +++ b/docs/deploy-lightsail.md @@ -0,0 +1,86 @@ +# Lightsail Deployment + +This deployment keeps monthly cost low while separating the database from the app server. + +## AWS Shape + +- Lightsail Linux instance for Caddy, static web files, and the API container. +- Lightsail managed PostgreSQL for application data. +- Optional S3 or Lightsail object storage for off-instance database dumps. + +Expected baseline cost is roughly $30-$35/month before optional snapshot and backup storage growth. + +## DNS + +Point the production domain at the Lightsail instance static IP. Caddy handles TLS automatically. + +## First Deploy + +Create a Lightsail PostgreSQL database, then create the deployment env files on the server: + +```bash +cp deploy/lightsail/.env.example deploy/lightsail/.env +cp deploy/lightsail/api.env.example deploy/lightsail/api.env +``` + +Update both files with the production domain, database endpoint, credentials, JWT secret, and SMTP credentials. + +Build and publish the frontend files: + +```bash +npm ci +VITE_BACKEND_URL=https://example.com/api npm run build:web +sudo mkdir -p /srv/nail-star/web +sudo rsync -a --delete apps/web/dist/ /srv/nail-star/web/ +``` + +Start the API and reverse proxy: + +```bash +docker compose -f deploy/lightsail/compose.yml --env-file deploy/lightsail/.env up -d --build +``` + +Verify: + +```bash +curl https://example.com/healthz +curl https://example.com/api/healthz +``` + +Both should return: + +```json +{"status":"ok"} +``` + +## Updating + +Pull the new code and repeat: + +```bash +VITE_BACKEND_URL=https://example.com/api npm run build:web +sudo rsync -a --delete apps/web/dist/ /srv/nail-star/web/ +docker compose -f deploy/lightsail/compose.yml --env-file deploy/lightsail/.env up -d --build +``` + +## Database Startup + +The API does not run Sequelize sync automatically in production. Run schema migrations or controlled setup before restarting production services. + +For an explicit one-time production sync, set this in `deploy/lightsail/api.env` before starting the API: + +```bash +DB_SYNC=true +``` + +Remove it after the sync has run. Use `DB_SYNC_ALTER=true` only for controlled maintenance, not normal production startup. + +## Backup Minimum + +Keep managed database backups enabled. Also schedule an off-instance dump, for example: + +```bash +PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -Fc > nail-star-$(date +%F).dump +``` + +Store dumps outside the app server, such as S3, Lightsail object storage, or another backup target. diff --git a/package.json b/package.json index ef17a87..d5dee35 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typecheck": "npm run typecheck --workspaces --if-present", "typecheck:web": "npm run typecheck --workspace @nail-star/web", "typecheck:api": "npm run typecheck --workspace @nail-star/api", + "docker:api": "docker build -f apps/api/Dockerfile -t nail-star-api .", "db:up": "npm run db:up --workspace @nail-star/api", "seed": "npm run seed --workspace @nail-star/api", "kill:8080": "npm run kill:8080 --workspace @nail-star/api"