The delivery path

A MERN application is more than a React build and an Express API. In a production deployment, the image supply chain, database boundary, network entry point, service identity, rollout behaviour and observability are all part of the application. EKS is useful when those concerns need a consistent operating model across services, but it introduces a platform that must be deliberately managed.

This walkthrough uses separate frontend and API images, Amazon ECR as the private registry, EKS for orchestration, a Kubernetes ClusterIP service behind ingress, and managed MongoDB rather than running a stateful database as an early cluster exercise. It is a reference architecture, not a claim that every MERN workload requires Kubernetes.

GitHub change
   │
   ▼
CI: test → build immutable images → scan → push
   │                                      │
   ▼                                      ▼
Helm values with image SHA              Amazon ECR
   │                                      │
   └──────────────► Amazon EKS ◄─────────┘
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
   Ingress/ALB   React frontend   Express API
                                      │
                                      ▼
                           MongoDB Atlas / managed database

Repository boundaries

Keep deployment code beside the application, but keep runtime secrets outside the repository. This layout allows a code review to inspect an application change and the manifest that deploys it together.

mern-eks/
├── client/                 # React application
│   ├── src/
│   └── Dockerfile
├── server/                 # Express API
│   ├── src/
│   ├── tests/
│   └── Dockerfile
├── helm/mern/
│   ├── Chart.yaml
│   ├── values.yaml
│   └── templates/
│       ├── api-deployment.yaml
│       ├── api-service.yaml
│       ├── frontend-deployment.yaml
│       ├── frontend-service.yaml
│       ├── ingress.yaml
│       └── serviceaccount.yaml
├── scripts/
│   ├── build-and-push.sh
│   └── verify.sh
└── Jenkinsfile

Create the ECR repositories and EKS access context

Create a dedicated repository per deployable image. Give the CI identity ECR push permissions and the EKS deployment identity only the Kubernetes access it requires. Do not use a long-lived administrator key in CI; use an IAM role or workload identity with a short session.

export AWS_REGION=ap-south-1
export CLUSTER_NAME=platform-eks
export AWS_ACCOUNT_ID=123456789012
export FRONTEND_REPO=mern-frontend
export API_REPO=mern-api

aws ecr create-repository --repository-name "$FRONTEND_REPO" --image-scanning-configuration scanOnPush=true --image-tag-mutability IMMUTABLE --region "$AWS_REGION"
aws ecr create-repository --repository-name "$API_REPO" --image-scanning-configuration scanOnPush=true --image-tag-mutability IMMUTABLE --region "$AWS_REGION"

aws eks update-kubeconfig --name "$CLUSTER_NAME" --region "$AWS_REGION"
kubectl create namespace mern-production
kubectl get nodes -o wide
kubectl auth can-i create deployments -n mern-production

Build, tag and push immutable images

Use a commit SHA as the deployment input. A mutable `latest` tag makes it harder to identify the code actually running and makes rollback ambiguous. The script below creates both a human-readable build tag and a commit tag, then Helm deploys the commit tag.

#!/usr/bin/env bash
# scripts/build-and-push.sh
set -euo pipefail
: "${AWS_REGION:=ap-south-1}"
: "${AWS_ACCOUNT_ID:?set AWS_ACCOUNT_ID}"
GIT_SHA=$(git rev-parse --short=12 HEAD)
REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"

aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$REGISTRY"
for spec in "frontend:client" "api:server"; do
  component=${spec%%:*}
  directory=${spec##*:}
  image="$REGISTRY/mern-$component:$GIT_SHA"
  docker build --pull --tag "$image" "./$directory"
  docker push "$image"
done
printf '%s\n' "$GIT_SHA"

Container hardening for the API

Use a multi-stage build, run Node as a non-root user, and make the process fail quickly on unhandled promise rejections. Add a health endpoint that verifies process availability without exposing configuration or secrets.

# server/Dockerfile
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY --chown=node:node package*.json ./
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "--unhandled-rejections=strict", "src/index.js"]

// server/src/index.js
import express from 'express';
const app = express();
app.use(express.json());
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
app.get('/api/v1/status', (_req, res) => res.json({ service: 'api', status: 'ready' }));
app.listen(process.env.PORT || 3000, '0.0.0.0');

Helm values: make runtime configuration explicit

Keep public, non-secret configuration in values files. Reference secrets by name rather than placing credentials in Helm values or command history. A separate values file per environment lets the chart remain predictable while deployment inputs differ.

# helm/mern/values.yaml
namespace: mern-production
image:
  registry: 123456789012.dkr.ecr.ap-south-1.amazonaws.com
  tag: replace-at-deploy
frontend:
  repository: mern-frontend
  replicas: 2
  containerPort: 80
api:
  repository: mern-api
  replicas: 2
  containerPort: 3000
  serviceAccountName: mern-api
  existingSecret: mern-api-runtime
  resources:
    requests: { cpu: 100m, memory: 128Mi }
    limits: { cpu: 500m, memory: 512Mi }
ingress:
  className: alb
  host: app.example.com

Deployment, service and health probes

A rolling update needs more than replicas. Readiness prevents traffic reaching a container before the API is ready, while liveness lets Kubernetes restart a stuck process. Set realistic resource requests and limits from measurements, then use Horizontal Pod Autoscaler only after metrics-server and baseline capacity are in place.

# helm/mern/templates/api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: mern-api, namespace: {{ .Values.namespace }} }
spec:
  replicas: {{ .Values.api.replicas }}
  strategy: { type: RollingUpdate, rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } }
  selector: { matchLabels: { app: mern-api } }
  template:
    metadata: { labels: { app: mern-api } }
    spec:
      serviceAccountName: {{ .Values.api.serviceAccountName }}
      securityContext: { runAsNonRoot: true }
      containers:
        - name: api
          image: "{{ .Values.image.registry }}/{{ .Values.api.repository }}:{{ .Values.image.tag }}"
          ports: [{ containerPort: 3000, name: http }]
          envFrom: [{ secretRef: { name: {{ .Values.api.existingSecret }} } }]
          resources: {{- toYaml .Values.api.resources | nindent 12 }}
          readinessProbe: { httpGet: { path: /health, port: http }, initialDelaySeconds: 5, periodSeconds: 10 }
          livenessProbe: { httpGet: { path: /health, port: http }, initialDelaySeconds: 20, periodSeconds: 20 }
          securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: ["ALL"] } }
---
apiVersion: v1
kind: Service
metadata: { name: mern-api, namespace: {{ .Values.namespace }} }
spec:
  type: ClusterIP
  selector: { app: mern-api }
  ports: [{ name: http, port: 80, targetPort: http }]

Ingress and AWS identity for workloads

Expose services through an ingress controller rather than giving every service a public load balancer. The AWS Load Balancer Controller requires its own properly scoped IAM role. For application-level AWS access such as S3 uploads, map a specific Kubernetes service account to a specific IAM role using IRSA (or EKS Pod Identity); do not rely on the node role or bake keys into an image.

# helm/mern/templates/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mern
  namespace: {{ .Values.namespace }}
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
    alb.ingress.kubernetes.io/ssl-redirect: '443'
spec:
  ingressClassName: {{ .Values.ingress.className }}
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend: { service: { name: mern-api, port: { number: 80 } } }
          - path: /
            pathType: Prefix
            backend: { service: { name: mern-frontend, port: { number: 80 } } }

# Create an API service account with a pre-created least-privilege IAM policy
eksctl create iamserviceaccount --cluster "$CLUSTER_NAME" --region "$AWS_REGION" --namespace mern-production --name mern-api --attach-policy-arn arn:aws:iam::123456789012:policy/MernApiS3Uploads --approve --override-existing-serviceaccounts

Deploy and verify the release

Use `--atomic` and `--wait` so Helm reverts the release automatically if Kubernetes cannot make the new version ready before the timeout. This protects the deployment operation, but it does not replace API smoke tests or monitoring after traffic begins flowing.

export IMAGE_TAG=$(git rev-parse --short=12 HEAD)
helm upgrade --install mern ./helm/mern --namespace mern-production --create-namespace --set image.tag="$IMAGE_TAG" --atomic --wait --timeout 10m

kubectl rollout status deployment/mern-api -n mern-production --timeout=5m
kubectl get pods,svc,ingress -n mern-production
kubectl describe ingress mern -n mern-production
ALB_DNS=$(kubectl get ingress mern -n mern-production -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
curl --fail --silent "https://$ALB_DNS/api/v1/status"

# Review the previous revision and roll back deliberately if needed
helm history mern -n mern-production
helm rollback mern 1 -n mern-production --wait --timeout 10m

Operational checklist and common failure modes

  • Confirm every running Pod uses the intended immutable image: `kubectl get pods -n mern-production -o jsonpath='{..image}'`.
  • If an image cannot be pulled, verify the image tag exists in ECR, node or pod ECR permissions, and that private subnets can reach ECR through NAT or VPC endpoints.
  • If Pods are Pending, inspect `kubectl describe pod`; common causes are a request larger than available node capacity, a missing PVC, a taint, or an unsatisfied affinity rule.
  • If ingress has no address, inspect the AWS Load Balancer Controller logs and its IAM policy, subnets and ingress annotations.
  • Do not log MongoDB connection strings, JWT secrets or AWS credentials. Store runtime secrets in a secrets manager and synchronize or inject them through a controlled mechanism.
  • Monitor API latency, error rate, restart count, HPA behaviour, node pressure and ALB target health. An EKS deployment is healthy only when both Kubernetes and the user-facing path are healthy.
← Back to all articles