Docker, Kubernetes, and AWS CI/CD Pipeline

Build a CI/CD pipeline that deploys a Node.js app to Kubernetes on AWS.

Introduction

30 Second Summary

Every time you change a line of code, getting that change safely onto a live server involves a chain of steps. Automating that chain is one of the most in-demand skills in tech today.

In this project, you will containerize a Node.js journaling app with Docker and deploy it to a Kubernetes cluster running on AWS. You will also build a CI/CD pipeline with GitHub Actions that automatically builds, pushes, and deploys your app on every code push.

What You'll Build

MindLog is a personal journaling app that you will containerize with Docker and deploy through a DevOps pipeline spanning AWS ECR, K3s on EC2, and GitHub Actions.

By the end of this project, you'll have:

  • A containerized Node.js app built with a multi-stage Docker build for a small image footprint.
  • A K3s Kubernetes cluster on AWS EC2 managing your app with Deployment and Service manifests.
  • A GitHub Actions CI/CD pipeline that automatically builds your image, pushes it to AWS ECR, and deploys to your cluster on every code push.
  • Secret Mission: Add a rolling update strategy and trigger a zero-downtime deployment.

Are there any prerequisites?

You need a GitHub account, a Linux Ubuntu machine, and basic familiarity with the terminal. Some understanding of what Docker does is helpful, but no hands-on experience is required.

Set Up Your Local Development Environment

Before you can build containers, push images, or deploy to Kubernetes, your Ubuntu machine needs the right tools installed.

This step walks you through installing Docker, Node.js, Git, kubectl, and the AWS CLI. You will also create an AWS account and configure programmatic access.

In this step, get ready to:
  • Install Docker Engine and verify it with a test container.
  • Install Node.js, Git, and kubectl from their official repositories.
  • Install the AWS CLI, create an AWS account, and configure credentials.
Install Docker Engine

Docker lets you package your application and all its dependencies into a single container image. Your Ubuntu machine needs Docker Engine installed from Docker's official apt repository.

  • Open your terminal.
  • Set up Docker's official apt repository by running these commands:
# Add Docker's official GPG key
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the Docker repository to apt sources
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update

What do these commands do?

These commands download Docker's official GPG signing key and add Docker's package repository to your system. This ensures that when you install Docker packages, they come from Docker's verified source.

  • Install the Docker Engine packages by running:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

What is each package for?

  • `docker-ce` is the Docker Engine itself.
  • `docker-ce-cli` gives you the `docker` command in your terminal.
  • `containerd.io` is the container runtime that Docker uses under the hood.
  • `docker-buildx-plugin` provides extended build capabilities for multi-stage builds.

By default, Docker commands require sudo. Adding your user to the docker group lets you run Docker without it.

  • Add your user to the docker group by running:
sudo usermod -aG docker $USER
newgrp docker

What does this do?

`usermod -aG docker $USER` adds your current user to the docker group. `newgrp docker` refreshes your group membership in the current terminal session so the change takes effect immediately.

  • Verify Docker is working by running:
docker run hello-world

You should see a message that says Hello from Docker! followed by an explanation of what just happened.

Seeing a permission denied error?

  • Make sure you ran both sudo usermod -aG docker $USER and newgrp docker. If the error persists, close your terminal and open a new one.
  • If Docker is not running, start it manually with sudo systemctl start docker.

Docker not working? Ask for help.

Install Node.js, Git, and kubectl

Your project needs Node.js to run the MindLog app, Git to push code to GitHub, and kubectl to manage your Kubernetes cluster.

  • Install Node.js 24 LTS by setting up the NodeSource repository and installing it:
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs

What does the NodeSource script do?

The curl command downloads a setup script from NodeSource that adds their official package repository to your system. After that, apt-get install nodejs pulls Node.js 24 directly from NodeSource instead of the default (usually older) Ubuntu packages.

The script will ask for confirmation

When you run the setup script, it may prompt you to confirm adding the repository. Press Enter or type Y to continue.

  • Install Git by running:
sudo apt-get install -y git

Now install kubectl, the command-line tool for talking to Kubernetes clusters.

  • Set up the Kubernetes apt repository and install kubectl by running these commands:
# Install prerequisites
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg

# Download the Kubernetes signing key
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
sudo chmod 644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg

# Add the Kubernetes apt repository
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo chmod 644 /etc/apt/sources.list.d/kubernetes.list

# Install kubectl
sudo apt-get update
sudo apt-get install -y kubectl

What does this do?

These commands add the official Kubernetes package repository to your system, similar to what you did for Docker. The GPG key ensures packages are verified. Then apt-get install kubectl installs the Kubernetes command-line tool.

  • Verify all three tools are installed by running:
node --version
git --version
kubectl version --client

You should see version numbers for each tool. node --version should show v24.x.x.

Command not found for any of these?

  • If node is not found, the NodeSource repository may not have been added correctly. Try running the curl setup script again.
  • If kubectl is not found, check that /etc/apt/sources.list.d/kubernetes.list exists with cat /etc/apt/sources.list.d/kubernetes.list. If the file is missing, re-run the kubectl install commands.

Help me troubleshoot my installations.

Install AWS CLI and create your AWS account

The AWS CLI lets you manage AWS resources from your terminal. You will use it to create an ECR repository, launch EC2 instances, and authenticate Docker with your container registry.

  • Download and install AWS CLI v2 by running:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

What do these commands do?

  • `curl` downloads the official AWS CLI installer as a zip file.
  • `unzip` extracts the installer files.
  • `sudo ./aws/install` runs the installer, placing the `aws` command at `/usr/local/bin/aws`.
  • Verify the installation by running:
aws --version

You should see output starting with aws-cli/2. followed by a version number.

Seeing command not found for unzip?

If unzip is not installed, run sudo apt-get install -y unzip first, then re-run the AWS CLI install commands.

Help me install the AWS CLI.

Now you need an AWS account so you can create cloud resources later in this project.

  • Open https://aws.amazon.com in your browser.
  • Click Create an AWS Account.
  • Follow the signup steps. Choose the Free Plan when prompted.

Do I need a credit card?

Yes, AWS requires a credit card for verification. You will not be charged for this project. New AWS accounts receive $200 in free credits ($100 at signup and $100 from activities). The free plan lasts 6 months or until credits are exhausted.

Once your account is created, you need an IAM user with programmatic access so the AWS CLI can authenticate.

  • Sign in to the AWS Management Console.
  • Search for IAM in the search bar at the top and open the IAM service.
  • Click Users in the left sidebar.
  • Click Create user.
  • Enter a username (e.g., devops-admin) and click Next.
  • Select Attach policies directly.
  • Search for AdministratorAccess and check the box next to it.
  • Click Next, then Create user.

Now create an access key for this user so the CLI can authenticate.

  • Click on your new user's name to open the user details page.
  • Click the Security credentials tab.
  • Scroll down to Access keys and click Create access key.
  • Select Command Line Interface (CLI) as the use case.
  • Check the confirmation checkbox and click Next.
  • Click Create access key.
  • Copy both the Access key ID and Secret access key to a safe place. You will need them in the next step.

Save your keys now

The Secret access key is only shown once. If you close this page without copying it, you will need to create a new access key.

  • Configure the AWS CLI with your credentials by running:
aws configure

The command prompts you for four values. Enter them as follows:

  • AWS Access Key ID: paste your access key ID.
  • AWS Secret Access Key: paste your secret access key.
  • Default region name: enter us-east-1.
  • Default output format: enter json.
  • Verify your AWS configuration is working by running:
aws sts get-caller-identity

You should see a JSON response containing your Account, UserId, and Arn. This confirms your CLI is authenticated.

Seeing an invalid credentials error?

  • Double check that you pasted the correct access key and secret key with no trailing spaces.
  • Run aws configure again to re-enter your credentials.
  • Make sure the IAM user has the AdministratorAccess policy attached.

Help me fix my AWS CLI credentials.

Your development environment is fully set up. You have Docker, Node.js, Git, kubectl, and the AWS CLI all installed and verified. Next up, you will explore the MindLog app and containerize it with Docker.

Explore the MindLog App and Containerize It

Your local machine is fully loaded with Docker, Node.js, Git, and the AWS CLI all installed and configured. The toolkit is ready. Now you need something to deploy.

MindLog is a personal journaling app with Markdown rendering, emoji mood tracking, and keyword search. In this step, you'll get the app running locally, then package it into a Docker container so it can run identically on any server.

In this step, get ready to:
  • Clone the MindLog repository and run the app locally.
  • Write a multi-stage Dockerfile to containerize the app.
  • Build and test the Docker image on your machine.
Clone the repository and run the app locally

The MindLog starter repository contains the full application code, including an Express server, EJS templates, and sample journal entries. Let's clone it and see the app in action.

  • Move to your Desktop and clone the MindLog repository by running these commands:
cd ~/Desktop
git clone https://github.com/nextwork-org/mindlog-starter.git mindlog
cd mindlog
  • Confirm you're in the project folder by running:
ls

You should see files like server.js, package.json, and folders like views/ and public/ listed in the output.

Clone failing?

Make sure you have an active internet connection. Double-check the URL for typos if you typed it manually.

I'm getting an error when trying to git clone the MindLog repository.

  • Install the project dependencies by running:
npm install

You should see npm download and install packages. When it finishes, a summary shows the number of packages added.

What does npm install do?

npm install reads package.json and downloads every library MindLog depends on. This includes Express for the web server, better-sqlite3 for the SQLite database, marked for Markdown rendering, and more.

npm install failed?

If you see permission errors, make sure you're running the command from inside the mindlog folder. If packages fail to compile, confirm that Node.js 24 is installed by running node --version.

npm install is failing with an error in my MindLog project.

  • Start the app by running:
node server.js

You should see MindLog running at http://localhost:3000 in your terminal.

  • Open your browser and go to http://localhost:3000.

The app comes pre-loaded with sample journal entries so you can explore right away.

  • Click New Entry to create a journal entry.
  • Filter entries by clicking a mood emoji at the top of the page.
  • Type a keyword into the search bar to search entries.
  • Visit http://localhost:3000/dashboard to see your journaling stats.
  • Once you're done exploring, go back to your terminal and press Ctrl+C to stop the server.

What's under the hood?

MindLog uses Express as its web framework and EJS for server-rendered templates. Data lives in a SQLite database file, so the entire app runs in a single process with no external database server needed.

The app also sanitizes Markdown with DOMPurify and seeds itself with sample data on first launch. This self-contained design makes it a perfect candidate for containerization.

Write the Dockerfile

You've seen MindLog running on your local machine. The goal now is to package it into a Docker container that can run identically on any server.

A Dockerfile is a recipe that tells Docker how to build an image of your app. You'll use a multi-stage build to keep the final image small.

  • Create a new file called Dockerfile in your mindlog folder by running:
touch Dockerfile
  • Open Dockerfile in your text editor and paste the following code:
# Stage 1: Install dependencies in a temporary builder layer
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

# Stage 2: Create the slim production image
FROM node:24-alpine
RUN apk add --no-cache tini
WORKDIR /app
# Copy only the installed node_modules from the builder stage
COPY --from=builder /app/node_modules ./node_modules
# Copy the rest of the application source code
COPY . .
# Create a directory for the SQLite database
RUN mkdir -p /app/data
EXPOSE 3000
# Run as a non-root user for security
USER node
# Use tini as the init process for proper signal handling
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

What does this Dockerfile do?

  • The first stage (builder) installs all Node.js dependencies using npm ci. This stage is temporary and discarded after the build.
  • The second stage starts from a fresh node:24-alpine image and copies only the node_modules folder from the builder. No build tools or caches carry over.
  • tini is a tiny init process that handles signals correctly inside containers. Without it, your app might not shut down cleanly.
  • USER node runs the app as a non-root user, which is a container security best practice.

Why multi-stage builds?

A single-stage build would include npm caches, build tools, and all of Alpine's package manager overhead in the final image. That bloats it to around 950MB.

Multi-stage builds let you install dependencies in one layer, then copy only the result into a clean layer. The final image drops to under 200MB.

  • Save Dockerfile.

File not recognized as a Dockerfile?

Make sure the file is named exactly Dockerfile with a capital D and no file extension. It should not be Dockerfile.txt or dockerfile.

My Dockerfile isn't being recognized when I try to build.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

FROM node:24-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
RUN mkdir -p /app/data
EXPOSE 3000
USER node
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Next, you need a .dockerignore file to tell Docker which files to skip when building the image.

  • Create a .dockerignore file in the same mindlog folder by running:
touch .dockerignore
  • Open .dockerignore in your text editor and paste the following:
node_modules
.git
.gitignore
*.md
data/*.db
.github
k8s

What does .dockerignore do?

Just like .gitignore tells Git which files to skip, .dockerignore tells Docker which files to exclude when copying your project into the image.

This keeps your image lean. For example, node_modules gets installed fresh inside the container by npm ci, so there's no reason to copy your local copy in.

  • Save .dockerignore.

✔️ Awesome, I've got everything!

Nice. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

node_modules
.git
.gitignore
*.md
data/*.db
.github
k8s
Build and test the Docker image locally

Your Dockerfile and .dockerignore are ready. Time to build the image and run it as a container to prove it works.

  • Build your Docker image by running this command:
docker build -t mindlog:latest .

The build takes about a minute. You should see each step complete, ending with a line that includes naming to docker.io/library/mindlog:latest.

Build failed?

Make sure you're running the command from inside the mindlog folder (the one containing your Dockerfile). If you see permission errors, confirm Docker is running and that your user is in the docker group.

My docker build command is failing with an error.

  • Run your container by running this command:
docker run -p 3000:3000 mindlog:latest
  • Open your browser and go to http://localhost:3000.

You should see MindLog running with the same sample entries as before. This time the app is running inside a Docker container, not directly on your machine.

  • Press Ctrl+C in your terminal to stop the container.

Port already in use?

If you see an error about port 3000 being in use, make sure you stopped the earlier node server.js process with Ctrl+C before running the container.

I'm getting a port already in use error when running my Docker container.

  • Check the image size by running:
docker images mindlog

You should see your mindlog:latest image listed at under 200MB. A standard Node.js image without multi-stage optimization would be closer to 950MB.

Why does image size matter?

Smaller images push and pull faster across the network. When your CI/CD pipeline builds and deploys on every commit, the difference between a 200MB and 950MB image adds up quickly.

Your app is containerized and tested. Next up, you'll push this image to a private container registry on AWS so your Kubernetes cluster can pull it.

Push Your Image to AWS ECR

You've got a working Docker image on your local machine. For a Kubernetes cluster running on AWS to pull and run it, the image needs to live in a registry that your AWS infrastructure can access.

In this step, you'll create a private repository on AWS ECR and push your MindLog image to it.

What is ECR?

Amazon Elastic Container Registry (ECR) is a private Docker image registry hosted on AWS. Think of it like a private version of Docker Hub that only your AWS account can access.

Because it is integrated with IAM, your Kubernetes cluster on AWS can authenticate and pull images without managing separate credentials.

In this step, get ready to:
  • Create a private ECR repository on AWS.
  • Push your Docker image to the registry.
Create an ECR repository

An ECR repository is a named storage location for your Docker images. You need one repository per application.

  • Create a new ECR repository called mindlog by running this command:
aws ecr create-repository --repository-name mindlog --region us-east-1

What does this command do?

  • aws ecr create-repository tells the AWS CLI to create a new private container registry.
  • --repository-name mindlog names the repository. This is how you will reference your images later.
  • --region us-east-1 creates the repository in the US East (N. Virginia) region, matching your AWS CLI configuration.

The command outputs a JSON response. Look for the repositoryUri field. It looks something like this:

"repositoryUri": "123456789012.dkr.ecr.us-east-1.amazonaws.com/mindlog"

Save your account ID

The 12-digit number at the start of the repositoryUri (before .dkr.ecr) is your AWS account ID. Copy it somewhere handy. You will use it in the next few commands.

Seeing an error?

  • If you see Unable to locate credentials, your AWS CLI is not configured. Run aws configure and enter your access key, secret key, region us-east-1, and output format json.
  • If you see RepositoryAlreadyExistsException, you already created the repository. That is fine. You can move on to the next substep.

Help me troubleshoot my ECR repository creation

Authenticate Docker to ECR and push your image

ECR is a private registry. Docker needs to authenticate with your AWS credentials before it can push images to it.

  • Log Docker into your ECR registry by running this command:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com

What does this command do?

  • aws ecr get-login-password generates a temporary authentication token from your AWS credentials.
  • The | pipe sends that token directly into docker login so Docker can authenticate with your private registry.
  • The registry URL is your account-specific ECR address (without the /mindlog part). Docker authenticates at the registry level, not the repository level.
  • You should see Login Succeeded in the output.

Login failed?

  • Double-check that you replaced the account ID placeholder with your actual 12-digit AWS account ID from the previous step.
  • Make sure there are no spaces around the account ID in the command. The full URL should be one continuous string like 123456789012.dkr.ecr.us-east-1.amazonaws.com.
  • If you see a permissions error, confirm your IAM user has the AdministratorAccess policy attached.

Help me troubleshoot my ECR Docker login

Now that Docker is authenticated, you can tag your local image with the ECR repository URI and push it.

  • Tag your local mindlog:latest image with your ECR repository URI by running this command:
docker tag mindlog:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/mindlog:latest

What does tagging do?

Docker images need a tag that includes the registry address before they can be pushed. The docker tag command creates a new reference to your existing image with the full ECR path. It does not copy or rebuild anything.

  • Push the tagged image to ECR by running this command:
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/mindlog:latest

What does this command do?

docker push uploads each layer of your image to the ECR repository. You will see progress bars as each layer uploads. This may take a minute depending on your internet speed.

  • Wait for the push to complete. You should see a final line showing the latest tag with a digest hash, confirming the upload succeeded.

Now verify that your image actually arrived in ECR by checking the AWS Console.

  • Open the AWS Console in your browser at console.aws.amazon.com.
  • In the search bar at the top, type ECR and select Elastic Container Registry.
  • Click Repositories in the left navigation pane.
  • Confirm the region in the top-right corner is set to US East (N. Virginia) us-east-1.
  • Click on the mindlog repository name.
  • You should see an image listed with the tag latest. This confirms your image was pushed successfully.

Don't see your image in ECR?

  • Check the region selector in the top-right of the console. Your repository is in us-east-1. If a different region is selected, you will see an empty repository list.
  • Make sure the docker push command completed without errors. Scroll up in your terminal and look for a digest hash at the end of the output.
  • If the push failed with an authorization error, your login token may have expired. Run the aws ecr get-login-password command again, then retry the push.

Help me troubleshoot my ECR image push

Your Docker image is now stored in a private registry on AWS. Next up, you'll launch an EC2 instance, install Kubernetes on it, and pull this image to run your app in the cloud.

Launch an EC2 Instance and Install K3s

Your Docker image is sitting in ECR, ready to be pulled. Now you need somewhere to run it.

In this step, you will spin up an EC2 instance on AWS and install K3s, a lightweight Kubernetes distribution. K3s gives you a fully conformant Kubernetes cluster on a single machine for a fraction of the cost of managed services like EKS.

In this step, get ready to:
  • Create a Security Group and launch an EC2 instance with K3s.
  • SSH into the instance and verify K3s is running.
  • Configure ECR authentication so the cluster can pull your private images.

Why K3s instead of EKS?

Amazon EKS charges $0.10 per cluster per hour, which adds up to about $73/month just for the control plane. There is no free tier for EKS.

K3s is a CNCF-certified Kubernetes distribution that uses the same kubectl commands and the same manifests. It runs on a single EC2 instance you can stop when you are not using it. That means $0 when idle.

Create a Security Group and launch the EC2 instance

Before launching an instance, you need to configure who can connect to it. A Security Group acts as a virtual firewall that controls inbound and outbound traffic for your EC2 instance.

  • Get your public IP address by running this command:
curl -s https://checkip.amazonaws.com

Copy the IP address that appears. You will use it in the next command.

  • Create a Security Group by running this command:
aws ec2 create-security-group \
  --group-name mindlog-sg \
  --description "Security group for MindLog K3s cluster" \
  --query 'GroupId' \
  --output text

This outputs a Security Group ID that looks like sg-0abc1234def56789. Copy it for the next commands.

  • Add inbound rules for SSH, HTTP, and your app port by running these three commands. Replace your public IP with the IP you copied earlier, and replace your security group ID with the ID from the previous command:
# Allow SSH access on port 22
aws ec2 authorize-security-group-ingress \
  --group-id [[SG_ID="your security group ID"]] \
  --protocol tcp \
  --port 22 \
  --cidr [[MY_IP="your public IP"]]/32

# Allow HTTP access on port 80
aws ec2 authorize-security-group-ingress \
  --group-id [[SG_ID="your security group ID"]] \
  --protocol tcp \
  --port 80 \
  --cidr [[MY_IP="your public IP"]]/32

# Allow app access on port 30000
aws ec2 authorize-security-group-ingress \
  --group-id [[SG_ID="your security group ID"]] \
  --protocol tcp \
  --port 30000 \
  --cidr [[MY_IP="your public IP"]]/32

What do these ports do?

  • Port 22 is for SSH so you can connect to the instance remotely.
  • Port 80 is for standard HTTP traffic.
  • Port 30000 is the NodePort where your Kubernetes Service will expose MindLog to the internet.

The /32 suffix means only YOUR IP address can access these ports. This keeps the instance locked down.

  • Create an SSH key pair for connecting to the instance by running:
aws ec2 create-key-pair \
  --key-name mindlog-key \
  --key-type rsa \
  --key-format pem \
  --query "KeyMaterial" \
  --output text > mindlog-key.pem
  • Lock down the file permissions so only you can read it:
chmod 400 mindlog-key.pem

Why chmod 400?

SSH requires that private key files are only readable by the owner. If the permissions are too open, SSH will refuse to use the key. The 400 permission means read-only for the file owner and no access for anyone else.

Now you need a user data script. This is a shell script that runs automatically when the EC2 instance boots for the first time.

  • Create the user data script by running this command:
cat > userdata.sh << 'EOF'
#!/bin/bash
# Install K3s (lightweight Kubernetes)
curl -sfL https://get.k3s.io | sh -

# Install AWS CLI (needed for ECR authentication during deploys)
apt-get update && apt-get install -y unzip
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
./aws/install
rm -rf awscliv2.zip aws
EOF

What does this script do?

  • The first line installs K3s using the official one-line installer. This sets up a fully working Kubernetes cluster.
  • The remaining lines install the AWS CLI on the instance. Your CI/CD pipeline will need this later to pull images from ECR during deployments.
  • Launch the EC2 instance by running this command. Replace your security group ID with your Security Group ID:
aws ec2 run-instances \
  --image-id resolve:ssm:/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id \
  --instance-type t3.small \
  --key-name mindlog-key \
  --security-group-ids [[SG_ID="your security group ID"]] \
  --user-data file://userdata.sh \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=mindlog-k3s}]' \
  --query 'Instances[0].InstanceId' \
  --output text

What does this command do?

  • The resolve:ssm: prefix automatically looks up the latest Ubuntu 24.04 AMI ID from AWS Systems Manager. This saves you from hardcoding an AMI ID that changes over time.
  • The t3.small instance type gives you 2 vCPUs and 2 GB of RAM. K3s needs at least 2 CPU cores and 2 GB RAM to run.
  • The --user-data flag tells EC2 to run your userdata.sh script on first boot.

Copy the Instance ID from the output. It looks like i-0abc1234def56789.

  • Wait for the instance to enter the running state by running:
aws ec2 wait instance-running --instance-ids [[INSTANCE_ID="your instance ID"]]
  • Get the public IP address of your instance by running:
aws ec2 describe-instances \
  --instance-ids [[INSTANCE_ID="your instance ID"]] \
  --query 'Reservations[0].Instances[0].PublicIpAddress' \
  --output text

Copy this IP address. You will need it throughout the rest of the project.

Instance terminated immediately?

If the instance moves to a terminated state instead of running, the most common cause is using an instance type not available in your availability zone. Verify you are in the us-east-1 region by running aws configure get region.

Help me troubleshoot why my EC2 instance terminated immediately after launch.

SSH in and verify K3s is running

The user data script needs a few minutes to finish installing K3s. Give it about 2-3 minutes, then connect.

  • SSH into your EC2 instance by running this command:
ssh -i mindlog-key.pem ubuntu@[[EC2_IP="your EC2 public IP address"]]

What should I see?

The first time you connect, SSH will ask if you want to continue connecting. Type yes and press Enter. You should see a terminal prompt that starts with ubuntu@.

  • Check that K3s is running by running:
sudo systemctl status k3s

You should see active (running) in green text.

  • Verify the Kubernetes node is ready by running:
sudo k3s kubectl get nodes

You should see one node with a Ready status. This confirms your single-node Kubernetes cluster is healthy.

Node showing NotReady?

K3s may still be starting up. Wait 30 seconds and run the command again. If it persists, check the K3s logs by running sudo journalctl -u k3s -f.

Help me debug why my K3s node is showing NotReady status.

  • Exit the SSH session to return to your local machine:
exit

Now you need to copy the K3s kubeconfig to your local machine so you can run kubectl commands without SSHing every time.

  • Create the ~/.kube directory if it does not already exist:
mkdir -p ~/.kube
  • Copy the kubeconfig file from the EC2 instance to your local machine by running:
scp -i mindlog-key.pem ubuntu@[[EC2_IP="your EC2 public IP address"]]:/etc/rancher/k3s/k3s.yaml ~/.kube/config

Permission denied on k3s.yaml?

The kubeconfig file is owned by root. SSH into the instance and copy it to a readable location first:

ssh -i mindlog-key.pem ubuntu@[[EC2_IP="your EC2 public IP address"]] "sudo cp /etc/rancher/k3s/k3s.yaml /tmp/k3s.yaml && sudo chmod 644 /tmp/k3s.yaml"

Then copy from /tmp/k3s.yaml instead:

scp -i mindlog-key.pem ubuntu@[[EC2_IP="your EC2 public IP address"]]:/tmp/k3s.yaml ~/.kube/config

The kubeconfig currently points to 127.0.0.1 as the server address. You need to change it to your EC2 instance's public IP.

  • Update the server address in the kubeconfig by running:
sed -i 's/127.0.0.1/[[EC2_IP="your EC2 public IP address"]]/g' ~/.kube/config
  • Verify that your local kubectl can talk to the cluster by running:
kubectl get nodes

You should see the same Ready node as before. This time the command is running from your local machine, not the EC2 instance.

Connection refused or timed out?

Open ~/.kube/config and verify the server field shows https:// followed by your EC2 public IP and port 6443. The port 6443 is the Kubernetes API port that K3s listens on.

Also make sure your Security Group allows access to port 6443. You may need to add another rule:

aws ec2 authorize-security-group-ingress \
  --group-id [[SG_ID="your security group ID"]] \
  --protocol tcp \
  --port 6443 \
  --cidr [[MY_IP="your public IP"]]/32

Help me debug why kubectl can't connect to my K3s cluster from my local machine.

Configure ECR authentication on the cluster

Your K3s cluster needs credentials to pull images from your private ECR repository. Without these credentials, Kubernetes would not be able to download your MindLog container image.

  • Create a Kubernetes Secret containing your ECR credentials by running this command. Replace your AWS account ID with your 12-digit AWS account number:
kubectl create secret docker-registry ecr-credentials \
  --docker-server=[[AWS_ACCOUNT_ID="your AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com \
  --docker-username=AWS \
  --docker-password=$(aws ecr get-login-password --region us-east-1)

What does this command do?

  • The docker-registry secret type stores registry authentication details that Kubernetes pods can use to pull private images.
  • The --docker-username=AWS is always AWS for ECR authentication.
  • The --docker-password retrieves a temporary token from ECR. This token is valid for 12 hours.

The secret name ecr-credentials matches the imagePullSecrets you will reference in your Kubernetes Deployment manifest in the next step.

  • Verify the secret was created by running:
kubectl get secrets

You should see ecr-credentials listed with type kubernetes.io/dockerconfigjson.

ECR tokens expire after 12 hours

The ECR authentication token is temporary. If your pods fail to pull images later, re-run the kubectl create secret command above (adding --dry-run=client -o yaml | kubectl apply -f - to update the existing secret). Your CI/CD pipeline in Step 6 will handle this automatically during deployments.

Error: secret already exists?

If you need to recreate the secret, delete the old one first:

kubectl delete secret ecr-credentials

Then run the kubectl create secret command again.

Help me fix ECR authentication issues in my K3s cluster.

Your Kubernetes cluster is running on AWS and your local kubectl is connected to it. Next up, you will write Kubernetes manifests and deploy MindLog to the cluster.

Deploy MindLog to Kubernetes

Your K3s cluster is running on EC2 and your Docker image is sitting in ECR, ready to be pulled.

Now you need to tell Kubernetes what to run and how to expose it to the outside world. You do this by writing manifests: YAML files that declare the desired state of your application.

In this step, get ready to:
  • Write a Deployment manifest that runs two replicas of MindLog with health checks.
  • Write a Service manifest that exposes your app on port 30000.
  • Access your live MindLog app running on AWS.
Write the Deployment manifest

A Deployment is a Kubernetes resource that manages a set of identical Pods. You tell it which container image to run, how many copies (replicas) you want, and how to check if they're healthy.

  • Create a k8s directory inside your mindlog project folder by running this command:
mkdir k8s
  • Confirm the directory was created by running:
ls

You should see k8s listed alongside your other project files like server.js and Dockerfile.

  • Create a new file called k8s/deployment.yaml and paste the following content into it:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mindlog
  labels:
    app: mindlog
spec:
  replicas: 2 # Run two identical copies of the app
  selector:
    matchLabels:
      app: mindlog
  template:
    metadata:
      labels:
        app: mindlog
    spec:
      imagePullSecrets:
        - name: ecr-credentials # The ECR secret you created in Step 4
      containers:
        - name: mindlog
          image: [[ECR_REPO_URI="123456789012.dkr.ecr.us-east-1.amazonaws.com/mindlog"]]:latest
          ports:
            - containerPort: 3000 # Port your Express app listens on
          resources:
            requests: # Minimum resources guaranteed
              memory: "128Mi"
              cpu: "100m"
            limits: # Maximum resources allowed
              memory: "256Mi"
              cpu: "250m"
          readinessProbe: # Kubernetes checks this before sending traffic
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10

What does this manifest do?

  • replicas: 2 tells Kubernetes to run two identical Pods. If one crashes, the other keeps serving traffic while Kubernetes restarts the failed one.
  • imagePullSecrets references the ecr-credentials secret you created in Step 4, so K3s can authenticate with your private ECR registry.
  • resources sets memory and CPU boundaries. Requests guarantee a minimum. Limits cap the maximum so one container can't starve the others.
  • readinessProbe hits the /health endpoint every 10 seconds. Kubernetes only routes traffic to a Pod once this probe succeeds.
  • Save k8s/deployment.yaml.

Not sure what to put for the image URI?

The image value should be the ECR repository URI you received back in Step 3 when you created the ECR repository. It follows the pattern 123456789012.dkr.ecr.us-east-1.amazonaws.com/mindlog. If you need to look it up again, run:

aws ecr describe-repositories --repository-names mindlog --query 'repositories[0].repositoryUri' --output text

help me find my ECR repository URI

✔️ Awesome, I've got everything!

Make sure you've saved k8s/deployment.yaml with your actual ECR repository URI in the image field.

ⓧ I'd like to double check the full code

Here is the complete k8s/deployment.yaml file. Replace ECR_REPO_URI with your actual ECR repository URI.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mindlog
  labels:
    app: mindlog
spec:
  replicas: 2
  selector:
    matchLabels:
      app: mindlog
  template:
    metadata:
      labels:
        app: mindlog
    spec:
      imagePullSecrets:
        - name: ecr-credentials
      containers:
        - name: mindlog
          image: ECR_REPO_URI:latest
          ports:
            - containerPort: 3000
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "256Mi"
              cpu: "250m"
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
Write the Service manifest and deploy

Your Deployment manages the Pods, but Pods don't have stable network addresses. A Service gives your Pods a single, stable entry point and handles routing traffic to whichever Pod is healthy.

You will use a NodePort Service, which opens a specific port on your EC2 instance so you can reach the app from your browser.

  • Create a new file called k8s/service.yaml and paste the following content into it:
apiVersion: v1
kind: Service
metadata:
  name: mindlog
spec:
  type: NodePort # Exposes the service on a static port on the EC2 instance
  selector:
    app: mindlog # Routes traffic to Pods with this label
  ports:
    - port: 80         # Port the Service listens on inside the cluster
      targetPort: 3000  # Port on the container to forward traffic to
      nodePort: 30000   # Port accessible from outside the cluster

What does this Service do?

Traffic flows in three hops: your browser hits port 30000 on the EC2 instance (nodePort), the Service receives it on port 80 (port), and forwards it to port 3000 on the container (targetPort).

The selector: app: mindlog label tells the Service which Pods to route to. This matches the labels you defined in your Deployment.

  • Save k8s/service.yaml.

Why NodePort instead of LoadBalancer?

On managed Kubernetes services like EKS, a LoadBalancer Service automatically provisions a cloud load balancer. On a bare EC2 instance running K3s, there is no cloud integration to create one.

NodePort is simpler and more portable. It works the same way on any Kubernetes cluster, which makes it a great starting point for learning.

✔️ Awesome, I've got everything!

Make sure you've saved k8s/service.yaml before moving on.

ⓧ I'd like to double check the full code

Here is the complete k8s/service.yaml file.

apiVersion: v1
kind: Service
metadata:
  name: mindlog
spec:
  type: NodePort
  selector:
    app: mindlog
  ports:
    - port: 80
      targetPort: 3000
      nodePort: 30000

Both manifests are ready. Time to apply them to your cluster.

  • Apply both manifests to your K3s cluster by running this command:
kubectl apply -f k8s/

You should see two lines of output confirming that both resources were created:

deployment.apps/mindlog created
service/mindlog created
  • Watch the Deployment rollout by running:
kubectl rollout status deployment/mindlog

Wait until you see deployment "mindlog" successfully rolled out. This means both replicas are running and passing their readiness probes.

  • Verify that your Pods are running by running:
kubectl get pods

You should see two Pods with a STATUS of Running and READY showing 1/1.

Pods stuck in ImagePullBackOff or ErrImagePull?

This means K3s cannot pull your image from ECR. Check these common causes:

  • Make sure the image value in deployment.yaml matches your ECR URI exactly, including the :latest tag.
  • Verify the ecr-credentials secret exists by running kubectl get secrets. If it's missing, go back to Step 4 and recreate it.
  • ECR credentials expire after 12 hours. If you created the secret more than 12 hours ago, delete it and recreate it with a fresh token.

Help me debug why my Kubernetes pods can't pull the image from ECR

Access your app on AWS

Your Deployment is running and your Service is routing traffic. MindLog is live on AWS.

  • Open your browser and navigate to:
http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]:30000

You should see the MindLog app loaded with all six seeded journal entries.

  • Click on one of the mood emoji filters to verify filtering works.
  • Type a keyword like Kubernetes into the search bar and confirm the results narrow down.
  • Click Dashboard in the navigation to see the stats page with mood counts and tag breakdowns.
  • Create a new journal entry to confirm writes are working.

Page not loading in the browser?

  • Make sure you are using http:// and not https://. Your app does not have TLS configured.
  • Check that your Security Group allows inbound traffic on port 30000 from your IP address. You configured this in Step 4.
  • Confirm your EC2 instance's public IP hasn't changed. You can find the current one in the EC2 Console under Instances.

Help me troubleshoot why I can't reach my app on EC2

MindLog is running on Kubernetes, pulling from your private container registry, with health checks and two replicas. Next up, you will automate the entire build-push-deploy flow with a GitHub Actions CI/CD pipeline so every git push deploys automatically.

Build the GitHub Actions CI/CD Pipeline

You deployed MindLog to Kubernetes by running commands manually from your terminal. Every future code change would mean rebuilding the Docker image, pushing it to ECR, and updating the deployment by hand.

A CI/CD pipeline automates that entire process. In this step, you'll create a GitHub Actions workflow that builds, pushes, and deploys your app automatically on every git push.

In this step, get ready to:
  • Push your MindLog project to a GitHub repository and configure secrets.
  • Write a GitHub Actions workflow that builds, pushes, and deploys automatically.
  • Trigger the pipeline and watch it deploy your app end to end.
Set up GitHub repository secrets

Your workflow needs access to AWS credentials and your EC2 instance to build, push, and deploy. GitHub repository secrets store sensitive values like API keys so your workflow can use them without exposing them in code.

First, you need to push your local MindLog project to a new GitHub repository. Then you'll add the secrets your workflow will reference.

  • Open your browser and go to github.com.
  • Click the + icon in the top right corner, then click New repository.
  • Enter mindlog as the repository name.
  • Leave all other options at their defaults. Do not initialize with a README, license, or .gitignore.
  • Click Create repository.

GitHub shows a Quick Setup page with a URL. You'll use this URL to connect your local project to this new remote repository.

  • Copy the HTTPS URL shown on the Quick Setup page. It looks like https://github.com/YOUR-USERNAME/mindlog.git.
  • Back in your terminal, make sure you are inside the mindlog project folder.
  • Stage and commit all your project files by running these commands:
git add -A
git commit -m "Add Docker and Kubernetes configuration"
  • Point your local repository to your new GitHub remote by running this command:
git remote set-url origin [[GITHUB_REPO_URL="https://github.com/YOUR-USERNAME/mindlog.git"]]
  • Push your code to GitHub by running:
git branch -M main
git push -u origin main

What do these commands do?

  • git remote set-url origin changes where your local repo pushes to. Since you cloned from a starter repo, the original remote is no longer useful.
  • git branch -M main ensures your default branch is named main, which is what the workflow will trigger on.
  • git push -u origin main uploads all your commits to your new GitHub repository.
  • Refresh your GitHub repository page in the browser.
  • You should see all your project files listed, including server.js, Dockerfile, package.json, and the k8s/ folder.

Push was rejected?

If you see a "remote contains work that you do not have locally" error, make sure you created the GitHub repository without initializing a README or other files. Delete the repository on GitHub and recreate it as an empty repository.

If you're prompted for a username and password, GitHub no longer accepts password authentication for HTTPS. Set up a personal access token in your GitHub settings and use it as your password.

Help me push my local repository to GitHub

Now you'll add four secrets to the repository. These let the workflow authenticate with AWS and SSH into your EC2 instance without hardcoding credentials in code.

  • On your GitHub repository page, click Settings in the top navigation bar.
  • In the left sidebar under Security, click Secrets and variables, then click Actions.
  • Click the Secrets tab.
  • Click New repository secret.

You need to create four secrets, one at a time. For each secret, enter the name in the Name field, paste the value in the Secret field, and click Add secret.

  • Create a secret with the name AWS_ACCESS_KEY_ID and paste your IAM access key ID as the value.
  • Create a secret with the name AWS_SECRET_ACCESS_KEY and paste your IAM secret access key as the value.
  • Create a secret with the name EC2_HOST and paste your EC2 instance's public IP address as the value.
  • Create a secret with the name EC2_SSH_KEY and paste the full contents of your .pem private key file as the value.

Copy your .pem key contents

To get your private key contents for the EC2_SSH_KEY secret, run cat on your .pem file in the terminal and copy the entire output, including the -----BEGIN RSA PRIVATE KEY----- and -----END RSA PRIVATE KEY----- lines.

  • After adding all four secrets, confirm that the Secrets page shows AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, EC2_HOST, and EC2_SSH_KEY listed.

Why store credentials as secrets?

GitHub encrypts secrets before they reach their servers. Your workflow references them with the ${{ secrets.NAME }} syntax, and GitHub automatically redacts their values from workflow logs.

This keeps your AWS credentials and SSH key out of your source code. Anyone who forks your repo won't see these values.

Write the GitHub Actions workflow

A GitHub Actions workflow is a YAML file that lives inside your repository. GitHub detects it automatically and runs the steps you define whenever the trigger conditions are met.

  • Create the workflow directory and file by running these commands:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Confirm the file was created by running:
ls .github/workflows/

You should see deploy.yml in the output.

You'll build the workflow in three chunks. The first chunk defines the trigger, environment variables, and the pipeline's setup steps.

  • Open .github/workflows/deploy.yml in your text editor and paste the following code:
# Workflow name shown in the GitHub Actions tab
name: Build and Deploy

# Trigger on every push to the main branch
on:
  push:
    branches: [main]

# Environment variables shared across all jobs
env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: mindlog

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      # Pull the latest code from the repository
      - name: Checkout code
        uses: actions/checkout@v4

      # Authenticate with AWS using the repository secrets
      - 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 }}

      # Log in to your private ECR registry
      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

What does this code do?

  • on: push: branches: [main] tells GitHub to run this workflow every time you push to the main branch.
  • The env block sets AWS_REGION and ECR_REPOSITORY so every step can reference them without repetition.
  • The first three steps handle plumbing: checking out the code, authenticating with AWS, and logging in to ECR. The ECR login step outputs the registry URL for later steps.

The next chunk is the build and push step. It compiles a Docker image from your repository and pushes it to ECR with two tags.

  • Add the following code directly below the last line of your file, at the same indentation level as the previous steps:
      # Build the Docker image and push it to ECR with two tags
      - name: Build and push Docker image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

What does this step do?

  • ECR_REGISTRY pulls the registry URL from the ECR login step's output. This means you never need to hardcode your AWS account ID.
  • IMAGE_TAG uses the Git commit SHA (${{ github.sha }}). Every build gets a unique, traceable tag tied to the exact commit that produced it.
  • The image is pushed twice: once with the commit SHA tag and once with latest. The SHA tag is what Kubernetes uses for the deployment update.

The final chunk is the deploy step. It SSHs into your EC2 instance and tells Kubernetes to update the running deployment.

  • Add the following code directly below the last line of your file, continuing at the same indentation level:
      # SSH into EC2 and update the Kubernetes deployment
      - name: Deploy to K3s on EC2
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ubuntu
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            aws ecr get-login-password --region us-east-1 | sudo k3s crictl pull --creds "AWS:$(aws ecr get-login-password --region us-east-1)" ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
            sudo k3s kubectl set image deployment/mindlog mindlog=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
            sudo k3s kubectl rollout status deployment/mindlog --timeout=120s

What does this step do?

  • appleboy/ssh-action@v1 is a GitHub Action that SSHs into a remote server using the host, username, and key you provide via secrets.
  • The first line of the script authenticates with ECR and pulls the new image onto the EC2 instance.
  • kubectl set image updates the mindlog Deployment to use the new image tag. Kubernetes then performs a rolling update, replacing old pods with new ones.
  • kubectl rollout status waits up to 120 seconds for the rollout to complete. If it fails, the workflow reports an error.

Why SSH instead of ArgoCD?

For a single-node K3s cluster, SSH-based deployment is the simplest approach that works reliably. ArgoCD is more production-grade but adds significant complexity and another tool to learn.

The SSH approach teaches the core CI/CD concept: a push triggers a build, which triggers a deploy. You can layer on ArgoCD later when managing multiple environments.

  • Save .github/workflows/deploy.yml.

YAML indentation errors?

YAML is very sensitive to indentation. Every level uses exactly 2 spaces. Make sure all the step entries under steps: are indented to the same level (6 spaces from the left edge).

If you have any tab characters in the file, replace them with spaces. YAML does not allow tabs.

Help me fix YAML indentation in my GitHub Actions workflow

✔️ Awesome, I've got everything!

Make sure you have saved .github/workflows/deploy.yml. The file should have 5 steps: checkout, configure AWS credentials, ECR login, build and push, and deploy to K3s.

ⓧ I'd like to double check the full code

Here is the complete deploy.yml workflow file. Make sure your file matches this exactly.

name: Build and Deploy

on:
  push:
    branches: [main]

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: mindlog

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        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: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push Docker image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

      - name: Deploy to K3s on EC2
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ubuntu
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            aws ecr get-login-password --region us-east-1 | sudo k3s crictl pull --creds "AWS:$(aws ecr get-login-password --region us-east-1)" ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
            sudo k3s kubectl set image deployment/mindlog mindlog=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
            sudo k3s kubectl rollout status deployment/mindlog --timeout=120s
Push and watch the pipeline run

The workflow triggers on every push to main. Committing and pushing the workflow file is all it takes to kick off your first automated pipeline run.

  • Commit and push the workflow file by running these commands:
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD pipeline"
git push origin main
  • Open your GitHub repository in the browser.
  • Click the Actions tab at the top of the repository page.
  • You should see a workflow run in progress called Add CI/CD pipeline. Click on it to see the details.
  • Click the deploy job to watch each step execute in real time.

The pipeline takes 2-4 minutes to complete. You'll see each step turn green as it succeeds: checkout, AWS credentials, ECR login, build and push, and finally the deploy to K3s.

Pipeline failed?

Click on the failed step to see the error message. Common issues include:

  • "Configure AWS credentials" fails: double check that your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets match the IAM credentials you configured with aws configure locally.
  • "Deploy to K3s on EC2" fails with SSH error: verify that EC2_HOST contains your EC2 public IP (not the DNS name) and that EC2_SSH_KEY contains the full private key including header and footer lines.
  • "Deploy to K3s on EC2" fails with aws: command not found: the AWS CLI needs to be installed on your EC2 instance. SSH in and install it manually.

Help me debug my GitHub Actions pipeline failure

  • Once all steps are green, verify the deployment updated by running this command in your terminal:
kubectl rollout status deployment/mindlog

You should see deployment "mindlog" successfully rolled out.

  • Open your browser and navigate to http://your EC2 public IP:30000 to confirm MindLog is still running.

Your CI/CD pipeline is live. In the next step, you'll prove it works end to end by making a visible code change and watching it flow automatically from commit to live deployment.

Test the Full Pipeline End-to-End

In the last step, you created a GitHub Actions workflow that builds, pushes, and deploys your app automatically.

Now it is time to prove it actually works. You will make a small, visible change to the MindLog app, push it to GitHub, and watch the entire pipeline run without any manual intervention.

In this step, get ready to:
  • Make a visible change to the app's header.
  • Push the change and verify the full CI/CD pipeline delivers it to your live Kubernetes cluster.
Make a visible change to the app

The ultimate test of a CI/CD pipeline is proving that a code change flows automatically from your editor to production. You will change the app title from "MindLog" to "MindLog v2" so you can instantly spot the update after the pipeline runs.

The EJS templates that render the app's HTML live inside the views/ directory. You need to find which file displays "MindLog" in the header.

  • Search for where "MindLog" appears in your EJS templates by running:
grep -rn "MindLog" views/

What does this command do?

The grep command searches for text inside files. The -r flag tells it to search all files and subdirectories inside views/ recursively. The -n flag prints the line number alongside each match so you can find the exact spot to edit.

You should see several lines of output showing the file paths and line numbers where "MindLog" appears. Look for the result that shows the app title in the header or navigation area.

  • Open the file that contains the header title. You can use any text editor (for example, nano or vim in the terminal, or your code editor).
  • Find the line that displays "MindLog" as the app title in the header.
  • Change MindLog to MindLog v2 in that line.
  • Save the file.

Only change the header title

The grep output might show "MindLog" in several files. You only need to change the one that appears as the main title in the app's header or navigation bar. Leave the other occurrences alone.

  • Stage and commit your change by running:
git add . && git commit -m "Update app title to v2"

You should see output confirming 1 file changed with a small number of insertions and deletions.

Seeing "nothing to commit"?

This means your file change was not saved. Re-open the EJS file, confirm the title reads MindLog v2, save it, then run the git add and git commit commands again.

Why is my git commit saying nothing to commit?

Push and verify the automated deployment

This is the moment of truth. A single git push will trigger the entire pipeline: build the Docker image, push it to ECR, SSH into your EC2 instance, and update the Kubernetes deployment. No manual steps required.

  • Push your commit to the main branch by running:
git push origin main

What happens behind the scenes?

Your push to the main branch triggers the GitHub Actions workflow you created in the previous step. The workflow runs through these stages automatically:

  • Checks out your latest code.
  • Authenticates with AWS and logs into ECR.
  • Builds a new Docker image tagged with the commit SHA.
  • Pushes the image to your ECR repository.
  • SSHs into your EC2 instance and updates the Kubernetes deployment to use the new image.
  • Open your GitHub repository in a browser.
  • Click the Actions tab at the top of the repository page.
  • Click on the latest workflow run titled Update app title to v2 to see the live progress.

You should see each step of the workflow executing in real time. The entire run takes about 2-3 minutes.

  • Wait for all workflow steps to show a green checkmark.
  • Confirm the deployment rolled out successfully by running:
kubectl rollout status deployment/mindlog

You should see a message like deployment "mindlog" successfully rolled out.

  • Open your browser and navigate to your app:
http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]:30000

You should see MindLog v2 displayed in the header. This confirms the full pipeline works: your code change went from a local commit, through GitHub Actions, into a new Docker image on ECR, and all the way to a live Kubernetes deployment on AWS.

Still seeing "MindLog" without the v2?

Try a hard refresh in your browser (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows/Linux) to clear cached content.

If that does not help, check the GitHub Actions workflow run for errors. Click the failed step to see the logs. Common causes include expired ECR credentials or an SSH connection timeout.

You can also check the pods directly by running kubectl get pods to see if they restarted with the new image.

My pipeline ran but I still see the old MindLog title. Help me debug.

  • Verify the pods are running with the updated image by running:
kubectl get pods

You should see 2 pods in the Running state with a recent age (a few minutes old, matching when the pipeline finished).

Secret mission

Add Rolling Updates and Zero-Downtime Deployment

Your pipeline works, but what happens to your users during a deployment? Right now, Kubernetes could take down both pods at once, causing a brief outage. In this mission, you'll configure a rolling update strategy that keeps your app online throughout every deployment.

Before you go

Clean Up Your Resources

This project uses AWS resources that cost money when running. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Cost warning

Your EC2 t3.small instance costs approximately $0.02 per hour when running. That adds up to roughly $15 per month if left on 24/7.

If you are not actively showcasing your project, stop your EC2 instance or delete your resources to avoid unexpected charges.

Resources you used:

  • 1x EC2 t3.small instance (costs ~$0.02/hour when running, $0 when stopped).
  • 1x ECR private repository (500MB free tier for private repos for 1 year).
  • 1x Security Group and 1x Key Pair (free).
  • GitHub repository with stored secrets (free).
  • Local project files and Docker images on your machine.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your pipeline running for live demos.

  • Your EC2 instance will continue running and your app will remain accessible at http://<ec2-public-ip>:30000.
  • Your GitHub Actions pipeline will continue to trigger on every push to main.
  • Expect costs of approximately $15/month if the instance runs 24/7.

Pause - I'll come back to this later

Stop your EC2 instance to bring costs to $0. K3s will restart automatically when you start the instance again.

  • Open the AWS Console and navigate to EC2.
  • Click Instances in the left sidebar.
  • Select your instance, then click Instance state and choose Stop instance.
  • Your ECR repository and GitHub secrets remain intact. When you are ready to resume, start the instance again from the same console.
  • Note that your EC2 public IP will change after stopping and starting. Update the EC2_HOST secret in your GitHub repository with the new IP.

Delete - I don't want to use this again

Remove all AWS resources and local files to bring your costs to $0 permanently.

  • Terminate your EC2 instance. In the EC2 console, select your instance, click Instance state, and choose Terminate instance.
  • Delete the ECR repository by running:
aws ecr delete-repository --repository-name mindlog --force --region us-east-1
  • Delete the Security Group. In the EC2 console, go to Security Groups in the left sidebar, select the group you created, and click Actions then Delete security groups.
  • Delete the Key Pair. In the EC2 console, go to Key Pairs in the left sidebar, select your key pair, and click Actions then Delete.
  • Optionally, remove the GitHub repository secrets from Settings > Secrets and variables > Actions.
  • Delete the local project folder and any .pem key files from your machine.
  • Clean up local Docker images by running:
docker rmi mindlog:latest
docker system prune -f

Mission accomplished

That's a Wrap!

Nice work! You just built a complete DevOps pipeline from scratch. A code change on your laptop now flows automatically through a build, a push to a private container registry, and a live deployment to a Kubernetes cluster running on AWS.

You've learned how to:

  • Containerized a Node.js app with a multi-stage Docker build, then pushed it to a private AWS ECR registry using IAM authentication.
  • Launched an EC2 instance running K3s, wrote Kubernetes manifests (Deployment + Service), and deployed your app to a real cluster.
  • Built a GitHub Actions CI/CD pipeline that automatically builds, pushes, and deploys on every commit, and tested the full end-to-end flow from code change to live on AWS.
  • Added rolling updates to your Deployment for zero-downtime deployments, proving your pipeline handles real production upgrade patterns.

Ready to quiz yourself?