DEV Community

Caleb Ajibade
Caleb Ajibade

Posted on AI-assisted

Deploying a Next.js and Spring Boot Monorepo to One EC2 Instance

I started with two repositories: a Next.js forum frontend and a Spring Boot API. I wanted one deployable unit: a monorepo, two immutable Docker images, and one EC2 instance exposing the complete application.

git push (devops)
        ↓
GitHub Actions using OIDC
        ↓
Amazon ECR: API image + frontend image
        ↓
private S3 release package → AWS Systems Manager → EC2
        ↓
Docker Compose: Nginx → Next.js at /, Spring Boot at /api, MongoDB privately
Enter fullscreen mode Exit fullscreen mode

The API is Java 21 / Spring Boot 3.5 with MongoDB and JWT authentication. The frontend is Next.js 15. Both now live in one repository:

forum-api/
├── src/                 # Spring Boot API
├── frontend/            # Next.js application
├── deploy/
│   ├── docker-compose.yml
│   ├── nginx.conf
│   └── deploy-on-ec2.sh
└── .github/workflows/deploy.yml
Enter fullscreen mode Exit fullscreen mode

1. Containerise both applications

The API image is built from the repository root with its existing multi-stage dockerfile. The frontend has its own multi-stage build in frontend/dockerfile:

FROM node:22-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

The important frontend build argument is:

docker build --file frontend/dockerfile \
  --build-arg NEXT_PUBLIC_API_BASE_URL=/api \
  --tag forum-frontend:local frontend
Enter fullscreen mode Exit fullscreen mode

/api is a relative browser URL. The browser calls the same hostname that served the frontend, which avoids exposing a second public port or relying on CORS between separate origins.

2. Run both containers behind Nginx

Docker Compose runs four services on the EC2 host: frontend, api, mongo, and nginx. Only Nginx publishes a host port.

services:
  api:
    image: ${IMAGE_URI:?Set IMAGE_URI to the ECR image URI}
    env_file: [${RUNTIME_ENV_FILE:-../.env}]
    expose: ["8080"]

  frontend:
    image: ${FRONTEND_IMAGE_URI:?Set FRONTEND_IMAGE_URI to the ECR image URI}
    expose: ["3000"]

  mongo:
    image: mongo:7
    volumes: [mongo-data:/data/db]

  nginx:
    image: nginx:1.27-alpine
    depends_on: [api, frontend]
    ports: ["80:80"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
Enter fullscreen mode Exit fullscreen mode

The Nginx configuration has two deliberately different routes:

location /api/ {
    proxy_pass http://api:8080;
}

location / {
    proxy_pass http://frontend:3000;
}
Enter fullscreen mode Exit fullscreen mode

The API proxy keeps the /api prefix because the Spring controllers are already mapped below it. Every other route, including Next.js page routes, reaches the frontend container.

3. Use two private ECR repositories

I created two immutable, scan-on-push ECR repositories in eu-north-1:

  • forum-api
  • forum-frontend

The GitHub deployment role can push only to those repositories. The EC2 instance role can pull only from those repositories and download releases from one private S3 bucket. This keeps AWS permissions narrow while still allowing one workflow to deploy the complete stack.

The GitHub repository variables are:

Variable Value
AWS_REGION eu-north-1
ECR_REPOSITORY forum-api
FRONTEND_ECR_REPOSITORY forum-frontend
EC2_INSTANCE_ID the target instance ID
DEPLOYMENT_BUCKET the private release bucket

The one GitHub environment secret is AWS_DEPLOY_ROLE_ARN. It is an IAM role ARN, not an access key. The JWT secret and runtime database/mail settings stay in /opt/forum-api/.env on EC2 and are never committed or copied into GitHub.

4. Build and deploy through GitHub Actions

The devops workflow uses GitHub OIDC to obtain short-lived AWS credentials. It builds both images, tags each one with the commit SHA, and only pushes a tag if it does not already exist because the ECR repositories reject overwritten tags.

docker build --file dockerfile --tag "$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" .
docker build --file frontend/dockerfile \
  --build-arg NEXT_PUBLIC_API_BASE_URL=/api \
  --tag "$REGISTRY/$FRONTEND_ECR_REPOSITORY:$IMAGE_TAG" frontend
Enter fullscreen mode Exit fullscreen mode

The workflow then packages deploy/, uploads it to private S3, and sends an SSM command to EC2. The host script logs into ECR, sets both image URIs, then runs:

docker compose -f deploy/docker-compose.yml pull
docker compose -f deploy/docker-compose.yml up -d --remove-orphans
Enter fullscreen mode Exit fullscreen mode

No SSH port, GitHub deploy key, or long-lived AWS access key is required on the server. On a small EC2 root disk, use Next.js output: "standalone" and copy only .next/standalone, .next/static, and public into the runtime image. This avoids downloading a full development dependency tree. I also prune unused images before a pull, never volumes or running containers.

5. Verification and next steps

After a successful workflow, visit the EC2 public IP or a domain pointed to it:

http://YOUR_EC2_PUBLIC_IP/
Enter fullscreen mode Exit fullscreen mode

You should see the Next.js frontend. A request such as /api/topics/ should be handled by Spring Boot through Nginx. The API, MongoDB, and frontend ports remain private to the Docker network; port 80 is the only public application port. Because Nginx reads its configuration when it starts, I explicitly recreate that one stateless container after extracting a release package; otherwise a changed bind-mounted configuration would not take effect until its next restart.

For a real production deployment, I would add a custom domain and HTTPS, configure a working SMTP provider, add an unauthenticated health endpoint, and move MongoDB to a managed database before the application needs durability across instance replacement.

The useful pattern here is modest but robust: a monorepo gives one release version, ECR gives immutable images, Nginx gives one public entry point, and GitHub Actions plus OIDC and SSM gives an automated deployment without managing server SSH credentials.

Top comments (0)