Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules
apps/*/node_modules
apps/*/dist
apps/*/coverage
coverage
.git
.gitignore
.dockerignore
*.log
.env
.env.*
!.env.example
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ apps/*/node_modules/
apps/*/dist/
apps/*/coverage/
apps/web/.tanstack/

deploy/lightsail/.env
deploy/lightsail/api.env
34 changes: 34 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
37 changes: 37 additions & 0 deletions apps/api/src/config/_tests_/database-startup.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
38 changes: 38 additions & 0 deletions apps/api/src/config/database-startup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Sequelize } from "sequelize";

type SyncOptions = Parameters<Sequelize["sync"]>[0];
type StartupEnv = Partial<Pick<NodeJS.ProcessEnv, "NODE_ENV" | "DB_SYNC" | "DB_SYNC_ALTER">>;

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<Sequelize, "sync">,
env: StartupEnv = process.env
) {
const syncOptions = getDatabaseSyncOptions(env);

if (syncOptions === null) {
return;
}

if (syncOptions === undefined) {
await sequelize.sync();
return;
}

await sequelize.sync(syncOptions);
}
19 changes: 19 additions & 0 deletions apps/api/src/routes/_tests_/health.routes.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
9 changes: 9 additions & 0 deletions apps/api/src/routes/health.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 5 additions & 5 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -35,6 +37,8 @@ app.use(cors({
credentials: true,
}))

app.use(healthRoutes);

// Register the RestResponse middleware before all routes
app.use(restResponse);

Expand All @@ -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, () => {
Expand Down
3 changes: 3 additions & 0 deletions deploy/lightsail/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
APP_DOMAIN=example.com
WEB_ROOT=/srv/nail-star/web
API_IMAGE=nail-star-api:latest
16 changes: 16 additions & 0 deletions deploy/lightsail/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions deploy/lightsail/api.env.example
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions deploy/lightsail/compose.yml
Original file line number Diff line number Diff line change
@@ -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:
86 changes: 86 additions & 0 deletions docs/deploy-lightsail.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down