Before diving into code, let's outline what we’re building:
-
Node.js Web App
- A simple Express.js server that responds with "Hello, World!"
- The server listens on port 3000
-
Containerization with Docker
- Package the app inside a Docker container
- Create a Dockerfile for building the container
-
Orchestration with Kubernetes
- Deploy the Docker container on Kubernetes
- Define a Kubernetes Deployment & Service
-
Infrastructure as Code with Terraform
- Use Terraform to provision AWS resources
- Deploy a Kubernetes cluster on AWS (EKS)
- Create networking, IAM roles, and security groups
-
Deployment to AWS
- Deploy the Terraform infrastructure
- Deploy the Kubernetes app on AWS EKS
Before we start coding, you need the following tools installed on your system:
✅ Node.js & npm (For the web app)
✅ Docker (For containerization)
✅ Kubernetes (kubectl & minikube or AWS EKS CLI)
✅ Terraform (For AWS infrastructure)
✅ AWS CLI (For cloud authentication & resource management)
Great! Let’s start with Step 1: Creating the Node.js Web App.
We'll build a basic Express.js server that listens on port 3000 and responds with "Hello, World!"
Open your terminal and run:
mkdir hello-world-app && cd hello-world-appRun the following command to create a package.json file:
npm init -yThis will generate a default package.json file.
We need Express.js to create a simple web server:
npm install expressNow, create a new file called server.js:
touch server.jsAt this point, you should have:
hello-world-app/
│── package.json
│── package-lock.json
└── server.js
Awesome! Now, let’s write the Node.js server code inside server.js.
Open server.js in your editor and add the following code:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('Hello, World!');
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});- Import Express.js
- Create a new Express app
- Define a GET route that responds with
"Hello, World!" - Listen on port 3000 (or any port set via
process.env.PORT) - Print a message when the server starts
Before we move to Docker, let’s test our app.
Run the following command:
node server.jsIf everything is working, you should see:
Server is running on http://localhost:3000
Now, open a browser and go to http://localhost:3000 You should see: Hello, World!
Great! Now, let’s Dockerize the Node.js app so we can run it inside a container.
A Dockerfile is a script that tells Docker how to build and run our application in a container.
Inside your project directory (hello-world-app), create a file named Dockerfile:
touch DockerfileNow, open Dockerfile in your editor and add the following content:
# Use an official Node.js image as the base image
FROM node:18-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy package.json and package-lock.json first (for better caching)
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application files
COPY . .
# Expose the port that the app runs on
EXPOSE 3000
# Define the command to run the app
CMD ["node", "server.js"]- FROM node:18-alpine → Uses a lightweight Node.js Alpine Linux image
- WORKDIR /app → Sets
/appas the working directory inside the container - COPY package.json ./* → Copies
package.jsonandpackage-lock.jsonfirst (for caching) - RUN npm install → Installs dependencies
- COPY . . → Copies the rest of the application files
- EXPOSE 3000 → Informs Docker that the app runs on port
3000 - CMD ["node", "server.js"] → Defines the command to start the server
This prevents unnecessary files (like node_modules) from being copied into the container.
Run:
touch .dockerignoreAdd the following inside .dockerignore:
node_modules
npm-debug.log
Now, let’s build the Docker image. Run:
docker build -t hello-world-app .This will:
- Read the
Dockerfile - Download the Node.js base image
- Copy files into the container
- Install dependencies
- Create a Docker image named
hello-world-app
After building, let’s run the app in a container:
docker run -p 3000:3000 hello-world-appNow, open http://localhost:3000 in your browser. You should still see: Hello, World!
Great! Now, let's move on to Step 5: Deploying the Dockerized App on Kubernetes. 🚀
Now that we have our Node.js app running in a Docker container, we need to deploy it to Kubernetes.
To run Kubernetes locally, you can use Minikube or, if deploying on AWS later, use EKS.
-
If using Minikube (for local Kubernetes):
minikube start
-
Verify that Kubernetes is running:
kubectl get nodes
A Deployment is responsible for managing replicas of our app.
touch deployment.yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: hello-world-app
spec:
replicas: 2
selector:
matchLabels:
app: hello-world-app
template:
metadata:
labels:
app: hello-world-app
spec:
containers:
- name: hello-world-app
image: hello-world-app:latest
ports:
- containerPort: 3000A Service allows us to expose the Deployment inside the cluster.
touch service.yamlapiVersion: v1
kind: Service
metadata:
name: hello-world-service
spec:
selector:
app: hello-world-app
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: NodePortRun the following commands:
kubectl apply -f deployment.yaml
kubectl apply -f service.yamlVerify that everything is running:
kubectl get pods
kubectl get servicesTo access the service, find the NodePort:
kubectl describe service hello-world-service | grep NodePortThen open in your browser:
http://<minikube-ip>:<nodeport>
For Minikube:
minikube service hello-world-serviceA NodePort is a way to expose a Kubernetes service to external traffic by opening a specific port on every node in the cluster.
How it works:
- Kubernetes assigns a port (between 30000-32767) on every node.
- Any traffic sent to
<NodeIP>:<NodePort>will be forwarded to the service, which directs it to the pods.
If your NodePort is 30001, you can access your service at:
http://<minikube-ip>:30001
or
http://<node-ip>:30001
2️⃣ What is the difference between having separate deployment.yaml and service.yaml vs. a single file?
Both approaches work exactly the same, but the difference is in organization and maintainability.
- Better organization: Easier to manage and edit different resources.
- Reusability: You can update or deploy only specific resources without modifying the entire file.
- Clear versioning: Useful when using Git or Infrastructure as Code.
- Less file clutter: Everything is in one place.
- Easier to apply: One
kubectl apply -f hello-world.yamldeploys both resources. - Good for small projects: When you don’t have too many services.
✅ Which one should you use?
- For small projects, a single YAML file is fine.
- For large projects, separate files are better.
Let’s analyze each part of the combined deployment & service YAML:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-world-app # Name of the Deployment
spec:
replicas: 2 # Number of pod replicas
selector:
matchLabels:
app: hello-world # Match pods with this label
template:
metadata:
labels:
app: hello-world # Labels assigned to pods
spec:
containers:
- name: hello-world-container # Name of the container inside the pod
image: hello-world-app # Docker image to use
ports:
- containerPort: 3000 # Expose port 3000 inside the container
---
apiVersion: v1
kind: Service
metadata:
name: hello-world-service # Name of the service
spec:
selector:
app: hello-world # This service targets pods with label "app: hello-world"
ports:
- protocol: TCP
port: 80 # Port on the service (inside the cluster)
targetPort: 3000 # Forward traffic to pod's container on port 3000
type: NodePort # Exposes service on a NodePort (external access)-
Deployment:
- Creates 2 replicas (pods) of the app.
- Each pod runs a container with the hello-world-app image.
- The pods are assigned the label app: hello-world.
-
Service:
- Selects pods with app: hello-world.
- Routes incoming traffic on port 80 to port 3000 in the pods.
- Uses NodePort, making the app accessible outside the cluster.
For separate files:
kubectl apply -f deployment.yaml
kubectl apply -f service.yamlFor a single file:
kubectl apply -f hello-world.yamlNow that we have our app and containerization set up, we need to provision our AWS infrastructure using Terraform.
Ensure Terraform is installed, then navigate to the directory containing your Terraform files and run:
terraform initThis initializes Terraform and downloads the required providers.
To check for syntax errors and validate the Terraform configuration, run:
terraform validateTo ensure consistency in formatting:
terraform fmtBefore applying changes, review what Terraform will create:
terraform planThis command shows a detailed preview of the resources Terraform will provision.
To deploy the AWS infrastructure, run:
terraform apply -auto-approveThis creates the following AWS resources:
- Amazon EKS Cluster (
eks.tf) - Amazon ECR Repository (
ecr.tf) - VPC and Subnets (
vpc.tf) - IAM Roles for EKS and Worker Nodes (
eks.tf) - Security Groups for EKS Cluster and Worker Nodes (
eks.tf)
Once the cluster is created, configure kubectl to interact with it:
aws eks --region eu-central-1 update-kubeconfig --name hello-world-clusterThis command updates your local kubeconfig to communicate with the newly created EKS cluster.
Apply the updated Kubernetes deployment and service files:
kubectl apply -f deployment.yaml
kubectl apply -f service.yamlTo check the current state of the Terraform-managed resources:
terraform state listTo get details of a specific resource:
terraform state show aws_eks_cluster.mainIf you ever need to tear down the infrastructure, use:
terraform destroy -auto-approveThis will remove all the AWS resources created by Terraform.
We updated the container image to pull from AWS ECR:
image: 850995538849.dkr.ecr.eu-central-1.amazonaws.com/hello-world-app:latestReason: Instead of using a local image, we now fetch the image directly from AWS ECR to ensure proper deployment in AWS EKS.
We retained the LoadBalancer type service:
type: LoadBalancerReason: This ensures that AWS automatically provisions an external Elastic Load Balancer (ELB) to expose our service to the internet.
If EKS waits for the ECR image to be pushed but Kubernetes resources (Deployment & Service) are applied after EKS is ready, this can cause a deadlock where:
- EKS waits for ECR (because
depends_onenforces that ECR must be ready). - ECR waits for EKS (because Kubernetes needs an active cluster to deploy).
- By moving Kubernetes resources (
deployment.yaml&service.yaml) into Terraform, we solve this issue. - Terraform automates everything, ensuring:
- ECR is created ✅
- Docker image is pushed ✅
- EKS is created ✅
- Kubernetes deployment is applied automatically ✅ (without a deadlock)
- We should automate IAM policy creation instead of manually defining IAM roles.
- Terraform will dynamically attach required policies to EKS & worker nodes.
- Terraform creates the ECR repository, but you need to update the image reference in Kubernetes.
image: "<aws-account-id>.dkr.ecr.<aws-region>.amazonaws.com/hello-world-app:latest"- Find your ECR repository URL by running:
terraform output ecr_repository_url
Example:
image: "123456789012.dkr.ecr.eu-west-1.amazonaws.com/hello-world-app:latest"✅ Now, Terraform will correctly deploy the container from AWS ECR.
Run:
aws --version✅ If AWS CLI is missing, install it from AWS CLI Installation Guide.
Now, run the Terraform setup step-by-step:
terraform initterraform validate✅ Ensure no errors appear.
terraform apply -auto-approve✅ Terraform will:
- Create AWS ECR
- Push the Docker image to ECR
- Deploy AWS EKS
- Automatically apply Kubernetes resources (
deployment.yaml&service.yaml)
kubectl get nodes✅ Expected output:
NAME STATUS ROLES AGE VERSION
ip-10-0-1-34.eu-west-1.compute.internal Ready <none> 5m v1.22
ip-10-0-2-45.eu-west-1.compute.internal Ready <none> 5m v1.22
kubectl get pods✅ Expected output:
NAME READY STATUS RESTARTS AGE
hello-world-app-5678abcd89-xyz34 1/1 Running 0 2m
kubectl get services✅ Expected output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-world-service LoadBalancer 10.100.200.100 abcdef123.elb.amazonaws.com 80:31234/TCP 10m
Once the LoadBalancer is created, open:
http://<EXTERNAL-IP>
Example:
http://abcdef123.elb.amazonaws.com
✅ You should see:
Hello, World!
✅ No manual updates needed beyond updating deployment.yaml.
✅ Terraform now fully manages AWS ECR, EKS, and Kubernetes Deployment.
✅ You are now production-ready! 🎉
You have successfully deployed a Node.js application on AWS using Terraform, Kubernetes, and Docker!