diff --git a/deployment-platforms/rest-express-docker-aws-ec2/.dockerignore b/deployment-platforms/rest-express-docker-aws-ec2/.dockerignore new file mode 100644 index 000000000000..ddd28271387e --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +prisma/generated diff --git a/deployment-platforms/rest-express-docker-aws-ec2/.env.example b/deployment-platforms/rest-express-docker-aws-ec2/.env.example new file mode 100644 index 000000000000..ae017a519ccd --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/.env.example @@ -0,0 +1 @@ +DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/DATABASE" diff --git a/deployment-platforms/rest-express-docker-aws-ec2/.github/workflows/deploy.yml b/deployment-platforms/rest-express-docker-aws-ec2/.github/workflows/deploy.yml new file mode 100644 index 000000000000..dde3ecb13e49 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/.github/workflows/deploy.yml @@ -0,0 +1,99 @@ +name: Deploy to AWS EC2 + +on: + push: + branches: + - main + - latest + +jobs: + deploy: + runs-on: ubuntu-latest + + env: + AWS_REGION: ${{ vars.AWS_REGION }} + ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }} + CONTAINER_NAME: ${{ vars.CONTAINER_NAME }} + CONTAINER_PORT: ${{ vars.CONTAINER_PORT }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Deploy to EC2 + uses: appleboy/ssh-action@8743aa11bfbda97acb45c151ae7a2e0b203f1914 + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + envs: ECR_REGISTRY,ECR_REPOSITORY,AWS_REGION,CONTAINER_NAME,CONTAINER_PORT + script: | + set -eu + + # Auth + aws ecr get-login-password --region "$AWS_REGION" | \ + docker login --username AWS --password-stdin "$ECR_REGISTRY" + + # Pull latest image + docker pull "$ECR_REGISTRY/$ECR_REPOSITORY:${{ github.sha }}" + + # Stop and remove existing container if running + if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + docker stop "$CONTAINER_NAME" + docker rm "$CONTAINER_NAME" + fi + + # Run database migrations + docker run --rm \ + -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ + "$ECR_REGISTRY/$ECR_REPOSITORY:${{ github.sha }}" \ + npx prisma migrate deploy + + # Run new container + docker run -d \ + --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + -p "$CONTAINER_PORT:3000" \ + -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ + "$ECR_REGISTRY/$ECR_REPOSITORY:${{ github.sha }}" + + # Verify container is running + sleep 5 + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "Container $CONTAINER_NAME is running" + docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + else + echo "Container $CONTAINER_NAME failed to start" + docker logs "$CONTAINER_NAME" --tail 50 + exit 1 + fi + + # Prune old images + docker image prune -f diff --git a/deployment-platforms/rest-express-docker-aws-ec2/.gitignore b/deployment-platforms/rest-express-docker-aws-ec2/.gitignore new file mode 100644 index 000000000000..ddd28271387e --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +prisma/generated diff --git a/deployment-platforms/rest-express-docker-aws-ec2/Dockerfile b/deployment-platforms/rest-express-docker-aws-ec2/Dockerfile new file mode 100644 index 000000000000..a0d57b8d0ab9 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/Dockerfile @@ -0,0 +1,35 @@ +# Stage 1: builder +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +COPY prisma ./prisma +COPY prisma.config.ts ./ +RUN npm install +RUN npx prisma generate + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# Stage 2: runner +FROM node:20-alpine AS runner + +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +COPY package*.json ./ +COPY prisma ./prisma +COPY prisma.config.ts ./ +RUN npm install --omit=dev + +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/prisma/generated ./prisma/generated + +USER appuser + +EXPOSE 3000 + +CMD ["node", "dist/src/index.js"] diff --git a/deployment-platforms/rest-express-docker-aws-ec2/README.md b/deployment-platforms/rest-express-docker-aws-ec2/README.md new file mode 100644 index 000000000000..7a041aa177fe --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/README.md @@ -0,0 +1,181 @@ +# REST API with Express, Docker & AWS EC2 + +This example shows how to deploy a **Prisma REST API** (Express + TypeScript) to **AWS EC2** using **Docker** and **GitHub Actions**. + +## Prerequisites + +- [Docker](https://www.docker.com/) and Docker Compose (for the Docker path) +- [Node.js](https://nodejs.org/) 20+ and a local PostgreSQL instance (for the non-Docker path) +- An AWS account (for deployment only) + +## Getting started + +### 1. Clone the repository + +```sh +git clone https://github.com/prisma/prisma-examples.git --depth=1 +cd prisma-examples/deployment-platforms/rest-express-docker-aws-ec2 +``` + +### 2. Run the app + +Choose one of the two local development paths below. + +--- + +#### Option A — Docker Compose (recommended) + +Copy the example env file (Docker Compose sets `DATABASE_URL` automatically, so no edits needed): + +```sh +cp .env.example .env +``` + +Start the app and a local Postgres database: + +```sh +docker compose up --build +``` + +The server is now running at `http://localhost:3000`. Migrations are applied automatically on startup. + +--- + +#### Option B — Local Node.js + PostgreSQL + +Create a `.env` file and set `DATABASE_URL` to your local database: + +```sh +cp .env.example .env +# edit .env and set DATABASE_URL, e.g.: +# DATABASE_URL="postgresql://prisma:prisma@localhost:5432/prisma" +``` + +Install dependencies and generate the Prisma Client: + +```sh +npm install +npx prisma migrate dev +``` + +Start the development server: + +```sh +npm run dev +``` + +The server is now running at `http://localhost:3000`. + +--- + +### 3. Use the REST API + +Create a user: + +```sh +curl -X POST http://localhost:3000/user \ + -H "Content-Type: application/json" \ + -d '{"email": "alice@prisma.io", "name": "Alice"}' +``` + +Create a post: + +```sh +curl -X POST http://localhost:3000/post \ + -H "Content-Type: application/json" \ + -d '{"title": "Hello Prisma", "content": "My first post", "authorEmail": "alice@prisma.io"}' +``` + +Publish a post: + +```sh +curl -X PUT http://localhost:3000/publish/1 +``` + +Fetch all published posts: + +```sh +curl http://localhost:3000/feed +``` + +Fetch a single post: + +```sh +curl http://localhost:3000/post/1 +``` + +Delete a post: + +```sh +curl -X DELETE http://localhost:3000/post/1 +``` + +--- + +## Deploying to AWS EC2 + +### 1. Set up AWS prerequisites + +**ECR repository** — create one if you haven't already: + +```sh +aws ecr create-repository --repository-name my-prisma-app --region us-east-1 +``` + +**EC2 instance** — launch an instance (Amazon Linux 2 or Ubuntu) and install Docker: + +```sh +# Amazon Linux 2 +sudo yum update -y +sudo amazon-linux-extras install docker -y +sudo service docker start +sudo usermod -aG docker ec2-user +``` + +The EC2 instance needs permission to pull images from ECR. Choose one option: + +- **Option 1 (recommended):** Attach an IAM instance role with the `AmazonEC2ContainerRegistryReadOnly` policy. No credentials are stored on the instance. +- **Option 2:** Run `aws configure` on the instance and enter an IAM access key that has ECR read permissions. + +This is required by the `deploy.yml` step that runs `aws ecr get-login-password` on the instance before pulling the Docker image. + +Make sure port `3000` (or your chosen `CONTAINER_PORT`) is open in the instance's security group. + +### 2. Configure GitHub secrets and variables + +In your repository, go to **Settings → Secrets and variables → Actions** and add: + +**Secrets** (sensitive values): + +| Name | Description | +|---|---| +| `AWS_ACCESS_KEY_ID` | AWS IAM access key with ECR and EC2 permissions | +| `AWS_SECRET_ACCESS_KEY` | Corresponding secret key | +| `EC2_HOST` | Public IP or DNS of your EC2 instance | +| `EC2_USER` | SSH username (e.g. `ec2-user` or `ubuntu`) | +| `EC2_SSH_KEY` | Private SSH key used to connect to EC2 | +| `DATABASE_URL` | PostgreSQL connection string for your production database | + +**Variables** (non-sensitive values): + +| Name | Example value | +|---|---| +| `AWS_REGION` | `us-east-1` | +| `ECR_REPOSITORY` | `my-prisma-app` | +| `CONTAINER_NAME` | `prisma-app` | +| `CONTAINER_PORT` | `3000` | + +### 3. How deployment works + +Copy [`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml) to `.github/workflows/` at the root of **your own repository**. Pushing to `main` or `latest` triggers the workflow, which performs the following steps: + +1. Authenticates with AWS using the configured IAM credentials. +2. Builds a Docker image using Buildx with GitHub Actions layer caching for faster rebuilds. +3. Pushes the image to ECR tagged with both the commit SHA and `latest`. +4. SSHs into your EC2 instance. +5. Runs `prisma migrate deploy` against your production database in a one-off container. +6. Pulls the new image. +7. Stops and removes the old container if one is running. +8. Starts the new container with `DATABASE_URL` injected at runtime. +9. Waits 5 seconds and verifies the container is running — prints logs and exits non-zero on failure. +10. Prunes old images to keep the EC2 disk clean. diff --git a/deployment-platforms/rest-express-docker-aws-ec2/docker-compose.yml b/deployment-platforms/rest-express-docker-aws-ec2/docker-compose.yml new file mode 100644 index 000000000000..58e2443cfde1 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/docker-compose.yml @@ -0,0 +1,26 @@ +services: + app: + build: . + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://prisma:prisma@postgres:5432/prisma + depends_on: + postgres: + condition: service_healthy + command: > + sh -c "npx prisma migrate deploy && node dist/src/index.js" + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: prisma + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U prisma"] + interval: 5s + timeout: 5s + retries: 5 diff --git a/deployment-platforms/rest-express-docker-aws-ec2/package.json b/deployment-platforms/rest-express-docker-aws-ec2/package.json new file mode 100644 index 000000000000..24d92e2f2508 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/package.json @@ -0,0 +1,26 @@ +{ + "name": "rest-express-docker-aws-ec2", + "version": "1.0.0", + "license": "MIT", + "scripts": { + "build": "prisma generate && tsc", + "typecheck": "tsc --noEmit", + "dev": "prisma generate && npm run typecheck && tsx src/index.ts", + "start": "node dist/index.js" + }, + "dependencies": { + "@prisma/adapter-pg": "7.5.0", + "@prisma/client": "7.5.0", + "dotenv": "^17.2.1", + "express": "5.1.0", + "pg": "^8.16.3", + "prisma": "7.5.0" + }, + "devDependencies": { + "@types/express": "5.0.5", + "@types/node": "22.18.12", + "@types/pg": "^8.15.6", + "tsx": "^4.20.6", + "typescript": "5.8.2" + } +} diff --git a/deployment-platforms/rest-express-docker-aws-ec2/prisma.config.ts b/deployment-platforms/rest-express-docker-aws-ec2/prisma.config.ts new file mode 100644 index 000000000000..53c528fda7cc --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/prisma.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'prisma/config' +import 'dotenv/config' + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: process.env.DATABASE_URL ?? '', + }, +}) diff --git a/deployment-platforms/rest-express-docker-aws-ec2/prisma/migrations/20240101000000_init/migration.sql b/deployment-platforms/rest-express-docker-aws-ec2/prisma/migrations/20240101000000_init/migration.sql new file mode 100644 index 000000000000..c1022c8f6c9a --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/prisma/migrations/20240101000000_init/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Post" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT, + "published" BOOLEAN NOT NULL DEFAULT false, + "authorId" INTEGER NOT NULL, + + CONSTRAINT "Post_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "Post_authorId_idx" ON "Post"("authorId"); diff --git a/deployment-platforms/rest-express-docker-aws-ec2/prisma/migrations/migration_lock.toml b/deployment-platforms/rest-express-docker-aws-ec2/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000000..044d57cdb0d5 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/deployment-platforms/rest-express-docker-aws-ec2/prisma/schema.prisma b/deployment-platforms/rest-express-docker-aws-ec2/prisma/schema.prisma new file mode 100644 index 000000000000..7b508a2d7236 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/prisma/schema.prisma @@ -0,0 +1,24 @@ +generator client { + provider = "prisma-client" + output = "./generated" +} + +datasource db { + provider = "postgresql" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + content String? + published Boolean @default(false) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} diff --git a/deployment-platforms/rest-express-docker-aws-ec2/src/index.ts b/deployment-platforms/rest-express-docker-aws-ec2/src/index.ts new file mode 100644 index 000000000000..147a2ef8e168 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/src/index.ts @@ -0,0 +1,15 @@ +import 'dotenv/config' +import express from 'express' +import { postRouter } from './routes/post.routes' +import { userRouter } from './routes/user.routes' + +const app = express() + +app.use(express.json()) +app.use(postRouter) +app.use(userRouter) + +const PORT = process.env.PORT || 3000 +app.listen(PORT, () => + console.log(`Server ready at: http://localhost:${PORT}`), +) diff --git a/deployment-platforms/rest-express-docker-aws-ec2/src/lib/prisma.ts b/deployment-platforms/rest-express-docker-aws-ec2/src/lib/prisma.ts new file mode 100644 index 000000000000..de79ed54549a --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/src/lib/prisma.ts @@ -0,0 +1,10 @@ +import { PrismaClient } from '../../prisma/generated/client' +import { PrismaPg } from '@prisma/adapter-pg' + +const databaseUrl = process.env.DATABASE_URL +if (!databaseUrl) { + throw new Error('Missing DATABASE_URL environment variable') +} + +const pool = new PrismaPg({ connectionString: databaseUrl }) +export const prisma = new PrismaClient({ adapter: pool }) diff --git a/deployment-platforms/rest-express-docker-aws-ec2/src/routes/post.routes.ts b/deployment-platforms/rest-express-docker-aws-ec2/src/routes/post.routes.ts new file mode 100644 index 000000000000..6751d6568594 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/src/routes/post.routes.ts @@ -0,0 +1,114 @@ +import { Router, Request, Response } from 'express' +import { Prisma } from '../../prisma/generated/client' +import { prisma } from '../lib/prisma' + +export const postRouter = Router() + +function parsePostId(id: string | string[], res: Response): number | null { + const postId = Number(id) + if (!Number.isInteger(postId) || postId <= 0) { + res.status(400).json({ error: `Invalid post ID: ${id}` }) + return null + } + return postId +} + +// GET /feed — all published posts +postRouter.get('/feed', async (_req: Request, res: Response) => { + try { + const posts = await prisma.post.findMany({ + where: { published: true }, + include: { author: true }, + }) + res.json(posts) + } catch { + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// GET /post/:id — single post by id +postRouter.get('/post/:id', async (req: Request, res: Response) => { + const postId = parsePostId(req.params.id, res) + if (postId === null) return + try { + const post = await prisma.post.findUnique({ + where: { id: postId }, + }) + if (!post) { + res.status(404).json({ error: `Post with ID ${req.params.id} not found` }) + return + } + res.json(post) + } catch { + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// POST /post — create post +postRouter.post('/post', async (req: Request, res: Response) => { + const { title, authorEmail, content } = req.body + if (!title || !authorEmail) { + res.status(400).json({ error: 'title and authorEmail are required' }) + return + } + try { + const author = await prisma.user.findUnique({ where: { email: authorEmail } }) + if (!author) { + res.status(404).json({ error: `No user found for email: ${authorEmail}` }) + return + } + const post = await prisma.post.create({ + data: { + title, + content, + author: { connect: { email: authorEmail } }, + }, + }) + res.status(201).json(post) + } catch { + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// PUT /publish/:id — publish a post +postRouter.put('/publish/:id', async (req: Request, res: Response) => { + const postId = parsePostId(req.params.id, res) + if (postId === null) return + try { + const post = await prisma.post.update({ + where: { id: postId }, + data: { published: true }, + }) + res.json(post) + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + res.status(404).json({ error: `Post with ID ${req.params.id} not found` }) + return + } + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// DELETE /post/:id — delete post +postRouter.delete('/post/:id', async (req: Request, res: Response) => { + const postId = parsePostId(req.params.id, res) + if (postId === null) return + try { + const post = await prisma.post.delete({ + where: { id: postId }, + }) + res.json(post) + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + res.status(404).json({ error: `Post with ID ${req.params.id} not found` }) + return + } + res.status(500).json({ error: 'Internal server error' }) + } +}) diff --git a/deployment-platforms/rest-express-docker-aws-ec2/src/routes/user.routes.ts b/deployment-platforms/rest-express-docker-aws-ec2/src/routes/user.routes.ts new file mode 100644 index 000000000000..13dd4d9c7c9b --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/src/routes/user.routes.ts @@ -0,0 +1,33 @@ +import { Router, Request, Response } from 'express' +import { Prisma } from '../../prisma/generated/client' +import { prisma } from '../lib/prisma' + +export const userRouter = Router() + +// POST /user — create user +userRouter.post('/user', async (req: Request, res: Response) => { + const { email, name } = req.body + if (!email) { + res.status(400).json({ error: 'email is required' }) + return + } + if (typeof email !== 'string' || !email.includes('@')) { + res.status(400).json({ error: 'email must be a valid email address' }) + return + } + try { + const user = await prisma.user.create({ + data: { email, name }, + }) + res.status(201).json(user) + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + res.status(409).json({ error: `Email ${email} is already in use` }) + return + } + res.status(500).json({ error: 'Internal server error' }) + } +}) diff --git a/deployment-platforms/rest-express-docker-aws-ec2/tsconfig.json b/deployment-platforms/rest-express-docker-aws-ec2/tsconfig.json new file mode 100644 index 000000000000..7a1a0cebcda5 --- /dev/null +++ b/deployment-platforms/rest-express-docker-aws-ec2/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "outDir": "dist", + "strict": true, + "lib": ["esnext"], + "esModuleInterop": true, + "module": "CommonJS", + "moduleResolution": "node", + "resolveJsonModule": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src"] +} diff --git a/tests/deployment-platforms.test.ts b/tests/deployment-platforms.test.ts index 100ea27893d0..49dcfbcfabd6 100644 --- a/tests/deployment-platforms.test.ts +++ b/tests/deployment-platforms.test.ts @@ -32,4 +32,8 @@ describe('Deployment Platform Examples', () => { describe('deployment-platforms/vercel', () => { test.skip('requires Vercel setup', () => {}) }) + + describe('deployment-platforms/rest-express-docker-aws-ec2', () => { + test.skip('requires Docker and AWS EC2 setup', () => {}) + }) })