diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bb74ca9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.next +node_modules +**/node_modules +coverage +.vscode +.idea +.DS_Store +.env +.env.* +!.env.example +backups +deploy/nginx/certs diff --git a/.env.example b/.env.example index 5ef785f..abd24da 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ +POSTGRES_USER=parcel +POSTGRES_PASSWORD=parcel_password +POSTGRES_DB=parcel_society DATABASE_URL="postgresql://parcel:parcel_password@localhost:5432/parcel_society?schema=public" APP_SECRET="replace-with-a-long-random-secret" +NEXTAUTH_SECRET="replace-with-a-long-random-secret" ADMIN_EMAIL="admin@example.org" ADMIN_PASSWORD="replace-with-a-development-password" +WEB_PORT=3000 NODE_ENV="development" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8923de6 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +COMPOSE_PROD=docker compose -f docker-compose.prod.yml +BACKUP_DIR?=backups +BACKUP_FILE?=$(BACKUP_DIR)/parcel-society-$$(date +%Y%m%d-%H%M%S).sql.gz +RESTORE_FILE?= + +.PHONY: dev prod-up prod-down logs migrate seed backup restore + +dev: + pnpm dev + +prod-up: + $(COMPOSE_PROD) up -d --build postgres web + +prod-down: + $(COMPOSE_PROD) down + +logs: + $(COMPOSE_PROD) logs -f --tail=200 + +migrate: + $(COMPOSE_PROD) run --rm migrate + +seed: + $(COMPOSE_PROD) run --rm --entrypoint pnpm migrate --filter @parcel-society/db seed + +backup: + mkdir -p $(BACKUP_DIR) + $(COMPOSE_PROD) exec -T postgres sh -c 'pg_dump -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"' | gzip > $(BACKUP_FILE) + @echo "Backup written to $(BACKUP_FILE)" + +restore: + @test -n "$(RESTORE_FILE)" || (echo "Usage: make restore RESTORE_FILE=backups/file.sql.gz" && exit 1) + gunzip -c $(RESTORE_FILE) | $(COMPOSE_PROD) exec -T postgres sh -c 'psql -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"' diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index baaa598..e552739 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -17,11 +17,22 @@ COPY . . RUN pnpm --filter @parcel-society/db db:generate RUN pnpm --filter @parcel-society/web build +FROM deps AS tools +COPY . . +RUN pnpm --filter @parcel-society/db db:generate + FROM node:22-alpine AS runner ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 WORKDIR /app -COPY --from=builder /app/apps/web/.next/standalone ./ -COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static -COPY --from=builder /app/apps/web/public ./apps/web/public +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public +USER nextjs EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" CMD ["node", "apps/web/server.js"] diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts new file mode 100644 index 0000000..7f55042 --- /dev/null +++ b/apps/web/app/api/health/route.ts @@ -0,0 +1,34 @@ +import { prisma } from "@parcel-society/db"; + +export const dynamic = "force-dynamic"; + +type HealthResponse = { + ok: boolean; + database: "connected" | "disconnected"; + timestamp: string; +}; + +export async function GET() { + const timestamp = new Date().toISOString(); + + try { + await prisma.$queryRaw`SELECT 1`; + + return Response.json({ + ok: true, + database: "connected", + timestamp, + } satisfies HealthResponse); + } catch (error) { + console.error("Health check failed", error); + + return Response.json( + { + ok: false, + database: "disconnected", + timestamp, + } satisfies HealthResponse, + { status: 503 }, + ); + } +} diff --git a/deploy/nginx/parcel-society.conf.example b/deploy/nginx/parcel-society.conf.example new file mode 100644 index 0000000..3b39c04 --- /dev/null +++ b/deploy/nginx/parcel-society.conf.example @@ -0,0 +1,41 @@ +# Example Nginx server block for Parcel Society. +# Replace your-domain.com and certificate paths before enabling. + +server { + listen 80; + listen [::]:80; + server_name your-domain.com; + + # For Let's Encrypt HTTP-01 challenges. If certbot manages Nginx, + # it may create this location for you. + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name your-domain.com; + + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1: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 https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 60s; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..abf4823 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,91 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_USER: ${POSTGRES_USER:-parcel} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:-parcel_society} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - parcel-society + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + target: runner + restart: unless-stopped + env_file: + - .env + environment: + NODE_ENV: production + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env} + ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} + APP_SECRET: ${APP_SECRET:-} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-} + PORT: 3000 + HOSTNAME: 0.0.0.0 + depends_on: + postgres: + condition: service_healthy + expose: + - "3000" + ports: + - "127.0.0.1:${WEB_PORT:-3000}:3000" + networks: + - parcel-society + + migrate: + build: + context: . + dockerfile: apps/web/Dockerfile + target: tools + profiles: + - tools + env_file: + - .env + environment: + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env} + ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} + APP_SECRET: ${APP_SECRET:-} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-} + depends_on: + postgres: + condition: service_healthy + command: pnpm --filter @parcel-society/db db:deploy + networks: + - parcel-society + + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + profiles: + - nginx + depends_on: + - web + ports: + - "80:80" + - "443:443" + volumes: + - ./deploy/nginx/parcel-society.conf.example:/etc/nginx/conf.d/default.conf:ro + - ./deploy/nginx/certs:/etc/nginx/certs:ro + networks: + - parcel-society + +volumes: + postgres-data: + +networks: + parcel-society: + driver: bridge diff --git a/docs/deployment.md b/docs/deployment.md index 751b7c7..c9970d5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,28 +1,235 @@ -# Deployment +# Deploying Parcel Society on a VPS -## Local Development +This guide targets an Ubuntu VPS running Docker Compose with PostgreSQL in a container, the Next.js app in a production container, and either a host-level Nginx reverse proxy or the optional Compose-managed Nginx service. -Install dependencies and run the development server: +## Server requirements + +- Ubuntu 22.04 LTS or newer. +- 1 vCPU and 2 GB RAM minimum for small pilots; use 2+ vCPU and 4+ GB RAM for heavier experiments. +- 20 GB+ disk, plus enough space for PostgreSQL data and backups. +- A DNS `A`/`AAAA` record pointing your domain to the VPS. +- Open inbound ports `22`, `80`, and `443`; keep PostgreSQL private. + +## Install Docker and Compose + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +sudo usermod -aG docker "$USER" +``` + +Log out and back in, then verify: + +```bash +docker --version +docker compose version +``` + +## Clone the repository + +```bash +sudo mkdir -p /opt/parcel-society +sudo chown "$USER":"$USER" /opt/parcel-society +git clone https://github.com/YOUR_ORG/Parcel-Society.git /opt/parcel-society +cd /opt/parcel-society +``` + +## Create `.env` + +Create `/opt/parcel-society/.env` and keep it out of source control: + +```dotenv +POSTGRES_USER=parcel +POSTGRES_PASSWORD=replace-with-a-long-random-database-password +POSTGRES_DB=parcel_society +DATABASE_URL=postgresql://parcel:replace-with-a-long-random-database-password@postgres:5432/parcel_society?schema=public + +ADMIN_EMAIL=admin@example.org +ADMIN_PASSWORD=replace-with-a-strong-admin-password +APP_SECRET=replace-with-at-least-32-random-bytes +NEXTAUTH_SECRET=replace-with-the-same-or-another-32-random-bytes +WEB_PORT=3000 +NODE_ENV=production +``` + +Generate secrets with: + +```bash +openssl rand -base64 32 +``` + +Use the same PostgreSQL password in `POSTGRES_PASSWORD` and `DATABASE_URL`. The production Compose file keeps PostgreSQL on the private Docker network and binds the web app to `127.0.0.1:3000` for reverse proxying. + +## Build and start production containers + +```bash +make prod-up +``` + +This builds the standalone Next.js image and starts `postgres` and `web` with `restart: unless-stopped`. + +## Run migrations + +Run database schema deployment after the database is healthy: + +```bash +make migrate +``` + +The current deployment command uses Prisma `db push` because this repository does not yet include migration files. If versioned Prisma migrations are added later, change `packages/db/package.json` to use `prisma migrate deploy` for `db:deploy`. + +## Create the first admin + +The application authenticates the environment admin with `ADMIN_EMAIL` and `ADMIN_PASSWORD`. To ensure the admin user/profile exists in the database and to create demo servers, run: + +```bash +make seed +``` + +You can then open `/admin/login`, enter those credentials, and manage servers. Rotate the password after sharing temporary access. + +## Configure Nginx and HTTPS + +### Recommended: host-level Nginx + +Install Nginx and Certbot on the VPS: + +```bash +sudo apt-get install -y nginx certbot python3-certbot-nginx +sudo cp deploy/nginx/parcel-society.conf.example /etc/nginx/sites-available/parcel-society +sudo sed -i 's/your-domain.com/example.org/g' /etc/nginx/sites-available/parcel-society +sudo ln -s /etc/nginx/sites-available/parcel-society /etc/nginx/sites-enabled/parcel-society +sudo nginx -t +sudo systemctl reload nginx +sudo certbot --nginx -d example.org +``` + +The sample config proxies `https://your-domain.com` to `http://127.0.0.1:3000`. + +### Optional: Compose-managed Nginx + +If you prefer the included Nginx container, adapt `deploy/nginx/parcel-society.conf.example` for container networking and certificate paths, then run: + +```bash +docker compose -f docker-compose.prod.yml --profile nginx up -d nginx +``` + +For most VPS deployments, host-level Nginx plus Certbot is simpler. + +## Check health and logs + +```bash +curl -fsS http://127.0.0.1:3000/api/health +make logs +``` + +A healthy response looks like: + +```json +{ + "ok": true, + "database": "connected", + "timestamp": "2026-05-10T00:00:00.000Z" +} +``` + +## Update deployment + +```bash +cd /opt/parcel-society +git pull +make prod-up +make migrate +make logs +``` + +If dependencies or Docker layers changed, `make prod-up` rebuilds the web image. + +## Backup PostgreSQL + +Backups are compressed SQL dumps written to `backups/` by default: + +```bash +make backup +``` + +Copy backups off the VPS, for example to object storage or another server. Test restore procedures regularly. + +## Restore PostgreSQL + +Stop application traffic before restoring if you are replacing production data: + +```bash +make restore RESTORE_FILE=backups/parcel-society-YYYYMMDD-HHMMSS.sql.gz +``` + +For a destructive full restore, you may need to drop/recreate the database or restore into a fresh volume first, depending on the dump contents and target database state. + +## Security notes + +- Use a strong, unique `ADMIN_PASSWORD` and rotate it when staff roles change. +- Do not expose PostgreSQL publicly; keep it on the Docker network or localhost only. +- Enable a firewall, for example `ufw allow OpenSSH`, `ufw allow 80/tcp`, `ufw allow 443/tcp`, then `ufw enable`. +- Use HTTPS for all real deployments; do not send admin credentials over plain HTTP. +- Back up the database before updates and on a regular schedule. +- Rotate `APP_SECRET`, `NEXTAUTH_SECRET`, database passwords, and admin credentials if they are shared or suspected to be exposed. +- Keep Ubuntu, Docker, and base images patched. + +## Common troubleshooting + +### `web` exits during startup + +Check logs: + +```bash +make logs +``` + +Verify `.env` contains `DATABASE_URL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and at least one of `APP_SECRET` or `NEXTAUTH_SECRET`. + +### Health check reports `database: "disconnected"` + +Confirm PostgreSQL is healthy and `DATABASE_URL` uses the Compose service name `postgres`: + +```bash +docker compose -f docker-compose.prod.yml ps +docker compose -f docker-compose.prod.yml logs postgres +``` + +### Migrations fail + +Run the migration command again after PostgreSQL is healthy: ```bash -pnpm install -pnpm db:generate -pnpm db:migrate -pnpm dev +make migrate ``` -The web app runs on by default. +If schema drift is reported, back up the database before applying manual fixes. -## Docker Compose +### Nginx returns 502 -Docker Compose starts PostgreSQL and the production Next.js web app: +Ensure the app is listening locally: ```bash -docker compose up --build +curl -v http://127.0.0.1:3000/api/health ``` -PostgreSQL is exposed on port 5432 and the web app is exposed on port 3000. +Then validate and reload Nginx: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` -## Production Placeholder +### Certificates fail to issue -Production deployment guidance will be expanded after authentication, migrations, data export, and admin workflows are implemented. Production deployments should use managed secrets, persistent PostgreSQL storage, HTTPS, backups, and documented migration procedures. +Verify DNS points to the VPS and ports `80` and `443` are reachable from the internet. Temporarily disable conflicting services that already bind those ports. diff --git a/packages/db/package.json b/packages/db/package.json index 8ab794e..655913f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -17,7 +17,8 @@ "db:generate": "prisma generate --schema prisma/schema.prisma", "db:migrate": "prisma migrate dev --schema prisma/schema.prisma", "db:studio": "prisma studio --schema prisma/schema.prisma", - "seed": "tsx scripts/seed.ts" + "seed": "tsx scripts/seed.ts", + "db:deploy": "prisma db push --schema prisma/schema.prisma" }, "dependencies": { "@prisma/client": "^6.7.0"