Deploy a Containerized App to AWS
Use Docker, Terraform, Ansible, and K3s to deploy an app to AWS.
Introduction
30 Second Summary
Every time you update a web app, someone has to get that code onto a server, install the right dependencies, and make sure everything stays running. Doing this by hand once is tedious. Doing it reliably every time is nearly impossible.
In this project, you will build a complete DevOps pipeline that takes a simple web app from your laptop to a live server on the internet. You will containerize it with Docker, provision a cloud server with Terraform, configure it with Ansible, and deploy it to a Kubernetes cluster.
What You'll Build
When you finish, you will type a public IP address into your browser and see your running application served by Kubernetes on AWS, provisioned and configured entirely through code you wrote.
By the end of this project, you'll have:
- A container image published to Docker Hub that anyone can pull and run your app with a single command.
- A cloud server you can create and destroy on demand with one Terraform command, proving your infrastructure is fully defined in code.
- A K3s Kubernetes cluster that configures itself automatically through an Ansible playbook, with zero manual SSH commands.
- Secret Mission: Add a second microservice and configure Kubernetes to load balance between them.
Are there any prerequisites?
You need an AWS account, a GitHub account, and a Mac with Homebrew installed. You will also create a free Docker Hub account during setup.
No prior experience with Docker, Terraform, Ansible, or Kubernetes is required. This project walks you through every tool from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This is your moment to commit to the goal and articulate why automating infrastructure with code matters more than clicking through consoles.
Install Your DevOps Toolkit
To deploy a containerized app to AWS, you need a chain of tools on your local machine. Each tool handles one part of the pipeline: building container images, provisioning cloud servers, configuring those servers, and authenticating with AWS.
In this step, you will install four CLI tools using Homebrew and the official AWS installer. By the end, every tool will be verified and ready to go.
In this step, get ready to:
- Install Docker Desktop and Terraform via Homebrew.
- Install Ansible and the AWS CLI.
- Configure your AWS credentials so Terraform can provision resources.
Install Docker Desktop and Terraform
Docker Desktop gives you the docker CLI for building and running container images. Terraform is HashiCorp's tool for defining cloud infrastructure in code.
- Install Docker Desktop by running this command:
brew install --cask docker
What does this command do?
The --cask flag tells Homebrew to install a graphical macOS application (Docker Desktop) rather than a command-line-only formula. This includes the Docker engine, CLI, and desktop interface.
Docker Desktop needs to be opened once before the docker command becomes available in your terminal.
- Press Cmd+Space to open Spotlight search.
- Type Docker and press Enter to launch Docker Desktop.
- Accept the Docker Subscription Service Agreement when prompted.
- Select Use recommended settings when asked about configuration.
- Enter your Mac password if prompted for privileged access.
- Wait for Docker Desktop to finish starting up (you will see a green indicator in the menu bar).
- Verify Docker is working by running:
docker --version
✔️ I see a version number
You should see something like Docker version 27.x.x printed. Docker is ready to go.
ⓧ Command not found
This means Docker Desktop has not finished initializing.
- Make sure Docker Desktop is fully open and showing a green status in your menu bar.
- Close your terminal and open a new one, then try docker --version again.
- If Docker Desktop is stuck on starting, restart your Mac and reopen Docker Desktop.
- Install Terraform by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
What do these commands do?
The first command adds HashiCorp's official Homebrew tap (a custom repository of formulas). The second installs Terraform from that tap, ensuring you get the official build.
- Verify Terraform is installed by running:
terraform --version
✔️ I see a version number
You should see something like Terraform v1.15.x printed. Terraform is ready.
ⓧ Command not found
The Homebrew tap may not have linked correctly.
- Run brew tap and confirm hashicorp/tap appears in the list.
- If it does not appear, run brew tap hashicorp/tap again.
- Try brew install hashicorp/tap/terraform again and check for errors in the output.
Install Ansible and the AWS CLI
Ansible automates server configuration over SSH. The AWS CLI lets you interact with Amazon Web Services from your terminal.
- Install Ansible by running:
brew install ansible
- Verify Ansible is installed by running:
ansible --version
✔️ I see version info
You should see Ansible's version and configuration details printed across several lines. Ansible is ready.
ⓧ Command not found
Homebrew may not have linked the binary.
- Run brew link ansible to create the symlink.
- Close and reopen your terminal, then try ansible --version again.
For the AWS CLI, AWS recommends their official macOS package installer rather than third-party sources.
- Download and install the AWS CLI by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
What do these commands do?
The first command downloads the official AWS CLI v2 installer package. The second runs the macOS package installer, which places the aws binary in /usr/local/bin/aws.
The sudo prefix means you will be prompted for your Mac password. Type it and press Enter (you will not see characters as you type).
- Verify the AWS CLI is installed by running:
aws --version
✔️ I see a version number
You should see something like aws-cli/2.x.x printed. The AWS CLI is ready.
ⓧ Command not found
The installer may not have completed successfully.
- Run which aws to check if the binary exists at /usr/local/bin/aws.
- If it is not found, re-run the sudo installer command above and check for errors.
- Close and reopen your terminal, then try aws --version again.
- Clean up the downloaded installer file by running:
rm AWSCLIV2.pkg
Configure your AWS credentials
Terraform needs credentials to create resources in your AWS account. The aws configure command stores your Access Key ID and Secret Access Key locally so all AWS tools can use them.
Where do I find my AWS access keys?
In the AWS console, click your account name in the top-right corner, then select Security credentials. Scroll to Access keys and click Create access key.
Copy both the Access Key ID and Secret Access Key before closing the dialog. You will only see the Secret Access Key once.
- Configure the AWS CLI by running:
aws configure
The command prompts you for four values, one at a time. Enter each and press Enter:
- AWS Access Key ID [None]: paste your Access Key ID.
- AWS Secret Access Key [None]: paste your Secret Access Key.
- Default region name [None]: type us-east-1.
- Default output format [None]: type json.
- Verify your credentials work by running:
aws sts get-caller-identity
✔️ I see my account info
You should see a JSON response containing your UserId, Account, and Arn. Your AWS credentials are configured and working.
ⓧ I see an error
The most common cause is a mistyped key.
- Run aws configure again and carefully re-paste your Access Key ID and Secret Access Key.
- Make sure you copied both keys from the AWS console before closing the creation dialog.
- If you closed the dialog without copying, create a new access key pair.
Keep your keys safe
Never commit your AWS credentials to Git or share them publicly. The aws configure command stores them in ~/.aws/credentials on your local machine, which is the recommended approach.
Before you move on, take a moment to predict: if you run all four version commands together, what do you expect to see?
- Run this combined check to confirm all four tools are installed:
docker --version && terraform --version && ansible --version && aws --version
You should see four version outputs printed one after the other, confirming Docker, Terraform, Ansible, and the AWS CLI are all working.
Missing one of the versions?
If any command fails, the && operator stops execution at that point. Scroll up to the install section for the failing tool and re-run its install command.
Help me figure out which tool isn't working.
Your DevOps toolkit is locked and loaded. Next up, you will build a containerized web app and push it to Docker Hub so your cluster can pull it later.
Containerize and Push Your App to Docker Hub
Your DevOps toolkit is ready. Now it's time to create the thing you'll actually deploy: a container image that any server can pull and run.
Kubernetes pulls container images from a registry to run your application. Before you can deploy to a cluster, you need an image published somewhere accessible. In this step, you'll build a simple Node.js app, package it into a Docker container, and push it to Docker Hub where your future cluster can reach it.
In this step, get ready to:
- Build a Docker container from a Node.js web app.
- Test the container locally to confirm it works.
- Push the container image to Docker Hub.
Create your Node.js app and Dockerfile
You'll create three files: a small Express web server, its package manifest, and a Dockerfile that packages everything into a container image.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir devops-app
cd devops-app
- Open this folder in VS Code by running:
code .
What if code . does not work?
If you see a "command not found" error, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then click File > Open Folder and select the devops-app folder on your Desktop.
- In VS Code, create a new file called package.json and paste the following code:
{
"name": "devops-app",
"version": "1.0.0",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "4.21.2"
}
}
What does this file do?
This is your project's manifest. It tells Node.js your app's name, entry point, and dependencies. The express dependency is a lightweight web framework that handles HTTP requests.
- Create another new file called app.js and paste the following code:
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.json({
service: 'devops-app',
status: 'running',
timestamp: new Date().toISOString(),
host: require('os').hostname()
});
});
app.listen(port, '0.0.0.0', () => {
console.log(`App listening on port ${port}`);
});
What does this code do?
This is your web application. It creates an Express server on port 3000 that returns a JSON response with the service name, status, current timestamp, and the container's hostname.
The host field uses os.hostname() to show the container ID. This becomes useful later when Kubernetes runs multiple replicas of your app.
- Confirm both files exist by running:
ls
You should see app.js and package.json listed in the output.
- Create one more file called Dockerfile (no file extension) and paste the following code:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY app.js ./
EXPOSE 3000
CMD ["node", "app.js"]
What does this Dockerfile do?
A Dockerfile is a recipe for building a container image. Each line is a step:
- FROM node:22-alpine starts from a lightweight Node.js base image.
- COPY package.json and RUN npm install install dependencies before copying app code. This lets Docker cache the install step.
- EXPOSE 3000 documents which port the app listens on.
- CMD ["node", "app.js"] tells Docker what command to run when the container starts.
- Save all three files in VS Code.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before continuing.
ⓧ I'd like to double check the full code
Here are all three files for reference. Confirm yours match exactly.
package.json:
{
"name": "devops-app",
"version": "1.0.0",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "4.21.2"
}
}
app.js:
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.json({
service: 'devops-app',
status: 'running',
timestamp: new Date().toISOString(),
host: require('os').hostname()
});
});
app.listen(port, '0.0.0.0', () => {
console.log(`App listening on port ${port}`);
});
Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY app.js ./
EXPOSE 3000
CMD ["node", "app.js"]
- Build your Docker image by running this command:
docker build -t devops-app .
You'll see Docker download the Node.js base image, install your dependencies, and copy your app code. The output ends with a line showing the image was successfully built and tagged.
Build failing with errors?
- Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your menu bar.
- Confirm your Dockerfile has no file extension. It should be exactly Dockerfile, not Dockerfile.txt.
- Check that you are in the devops-app folder when running the command. The . at the end refers to the current directory.
Why is my Docker build failing?
- Run your container in the background by running:
docker run -d --name devops-test -p 3000:3000 devops-app
What does this command do?
The -d flag runs the container in detached mode so it runs in the background. The --name devops-test gives it a friendly name. The -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container.
Before you test the container, what do you expect to see when you send a request to port 3000?
- Test your running container by running:
curl http://localhost:3000
You should see a JSON response like this:
{"service":"devops-app","status":"running","timestamp":"2026-07-28T...","host":"a1b2c3d4e5f6"}
The host value is the container's short ID. This confirms your app is running inside Docker, not directly on your machine.
Seeing "connection refused"?
- Run docker ps to check if the container is running. If it is not listed, run docker logs devops-test to see what went wrong.
- Make sure nothing else is using port 3000 on your machine.
Why is my Docker container not responding on localhost:3000?
- Stop and remove the test container by running:
docker stop devops-test && docker rm devops-test
Push your image to Docker Hub
Your image works locally. Now you need to push it to a registry so your future Kubernetes cluster can pull it from the internet.
- Log in to Docker Hub by running:
docker login
What will Docker login ask me?
By default, docker login uses a device code flow. It will display a URL and a code. Open the URL in your browser, enter the code, and authenticate through the Docker Hub website. If you are already signed in to Docker Desktop, you may be logged in automatically.
Alternatively, you can run docker login -u your-username to log in with your username and be prompted for your password.
Once authenticated, you'll see a "Login Succeeded" message in the terminal.
- Tag your image with your Docker Hub username by running:
docker tag devops-app [[DOCKERHUB_USER="your-docker-hub-username"]]/devops-app:v1
Why do I need to tag the image?
Docker Hub organizes images by username. The tag tells Docker where to push the image. The format is username/image-name:version. The v1 tag lets you track versions as you update your app.
- Push the tagged image to Docker Hub by running:
docker push [[DOCKERHUB_USER="your-docker-hub-username"]]/devops-app:v1
You'll see Docker upload each layer of your image. This takes a moment depending on your internet connection.
- Verify your image is on Docker Hub by opening this URL in your browser:
https://hub.docker.com/r/[[DOCKERHUB_USER="your-docker-hub-username"]]/devops-app
You should see your devops-app repository listed with the v1 tag.
Push denied or authentication error?
- Run docker login again and make sure authentication succeeds with "Login Succeeded".
- Double-check that the tag matches your exact Docker Hub username. It must be lowercase.
- If you see "requested access to the resource is denied", your username in the tag does not match your logged-in account.
Why is my Docker push being denied?
Your container image is now published and accessible from anywhere. Next up, you'll use Terraform to provision an EC2 server in AWS where this container will eventually run.
Provision an EC2 Instance with Terraform
Your container image is sitting in Docker Hub, ready to be pulled by any machine in the world. But it needs a machine to pull it.
In this step, you will use Terraform to define an AWS server in code and create it with a single command. You will also discover the gap that a bare server leaves: no Kubernetes installed, no way to run your container.
In this step, get ready to:
- Write Terraform configuration that defines an EC2 instance with networking and security rules.
- Generate an SSH key pair and deploy the infrastructure to AWS.
- Discover that a bare server cannot run containers without Kubernetes.
Write the Terraform configuration
Terraform reads .tf files in your project directory and builds whatever infrastructure they describe. You will create two files: main.tf for the resources and outputs.tf to print useful values after deployment.
- Create a new file called main.tf in your devops-app project folder.
- Paste the following code into main.tf to set up the AWS provider and define a key pair resource:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.92"
}
}
required_version = ">= 1.2"
}
provider "aws" {
region = "us-east-1"
}
resource "aws_key_pair" "devops_key" {
key_name = "devops-key"
public_key = file("devops-key.pub")
}
What does this code do?
The terraform block pins the AWS provider to version ~> 5.92, meaning it accepts patch updates in the 5.x series but not a jump to 6.x.
The provider block tells Terraform to create resources in the us-east-1 region. The aws_key_pair resource uploads your public SSH key to AWS so you can log into the server later.
- Save main.tf.
- Initialize Terraform to download the AWS provider by running this command:
terraform init
You should see output that includes Terraform has been successfully initialized and a line showing the AWS provider was installed.
Seeing a provider error?
- Make sure your main.tf file is saved and you are running terraform init from the same directory that contains it.
- Check that the source value is exactly hashicorp/aws with no typos.
Still stuck? Help me debug my terraform init error
- Now add the security group resource below the key pair in main.tf. This first block opens ports for SSH, HTTP, and HTTPS:
resource "aws_security_group" "devops_sg" {
name = "devops-sg"
description = "Allow SSH, HTTP, HTTPS, K3s API, and NodePort traffic"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
What are these ingress rules?
Each ingress block opens one inbound port range. Port 22 allows SSH access so you can log in. Ports 80 and 443 allow standard web traffic.
- Directly below the HTTPS ingress block you just pasted (still inside the security group resource), add the Kubernetes-specific ports and the egress rule:
ingress {
from_port = 6443
to_port = 6443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 30000
to_port = 32767
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "devops_server" {
ami = "ami-0e1bed4f06a3b463d"
instance_type = "t3.micro"
key_name = aws_key_pair.devops_key.key_name
vpc_security_group_ids = [aws_security_group.devops_sg.id]
tags = {
Name = "devops-k3s-server"
}
}
What do these resources do?
Port 6443 is the Kubernetes API port that K3s uses. Ports 30000-32767 are the NodePort range where Kubernetes exposes your app to the internet.
The egress block allows all outbound traffic so the server can download packages. The aws_instance resource creates a t3.micro EC2 instance running Ubuntu 22.04, attached to your key pair and security group.
- Save main.tf.
- Now create a second file called outputs.tf in the same directory and paste this code:
output "server_public_ip" {
description = "Public IP of the EC2 instance"
value = aws_instance.devops_server.public_ip
}
What does this output do?
After Terraform creates the instance, it prints the public IP address to your terminal. You will use this IP to SSH into the server and access your running app later.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved both main.tf and outputs.tf.
ⓧ I'd like to double check the full code
Scroll up and confirm your main.tf contains these sections in order:
- The terraform block with required_providers and required_version.
- The provider "aws" block with region = "us-east-1".
- The aws_key_pair resource reading devops-key.pub.
- The aws_security_group resource with 5 ingress blocks (ports 22, 80, 443, 6443, 30000-32767) and 1 egress block.
- The aws_instance resource using ami-0e1bed4f06a3b463d and t3.micro.
Your outputs.tf should contain a single output block named server_public_ip referencing aws_instance.devops_server.public_ip.
Generate an SSH key and deploy the infrastructure
Your Terraform configuration references a file called devops-key.pub that does not exist yet. You need to generate an SSH key pair before Terraform can read the public key and upload it to AWS.
- Generate an SSH key pair by running this command:
ssh-keygen -t ed25519 -f devops-key -N ""
What does this command do?
- -t ed25519 selects a modern, fast key algorithm.
- -f devops-key names the output files devops-key (private) and devops-key.pub (public).
- -N "" sets an empty passphrase so you do not get prompted every time you SSH.
- Confirm the key files were created by running:
ls devops-key*
You should see devops-key and devops-key.pub listed.
- Now run Terraform's plan command to preview what resources will be created:
terraform plan
You should see a plan showing 3 resources to add: aws_key_pair, aws_security_group, and aws_instance.
Seeing a file not found error?
- If Terraform reports it cannot find devops-key.pub, make sure you ran ssh-keygen from the same directory that contains main.tf.
- Run ls and verify that devops-key.pub, main.tf, and outputs.tf are all in the same folder.
Need more help? Help me fix my terraform plan error
Before you run the next command, what do you think will happen when Terraform applies this plan?
- Deploy the infrastructure by running:
terraform apply
- When prompted, type yes and press Enter to confirm.
After about a minute, Terraform finishes and prints your server's public IP address in the server_public_ip output.
What just happened?
Terraform sent API requests to AWS to create a key pair, a security group with your firewall rules, and an EC2 instance. All three resources now exist in your AWS account. The public IP is your server's address on the internet.
Apply failed with an error?
- If you see a credentials error, confirm aws sts get-caller-identity still works. Your AWS CLI credentials may have expired.
- If the AMI is not found, the Ubuntu image ID may have changed for your region. Run the following command and replace the AMI in main.tf with the result:
aws ec2 describe-images --owners 099720109477 --filters "Name=name,Values=ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*" --query 'sort_by(Images, &CreationDate)[-1].ImageId' --output text
Still stuck? Help me debug my terraform apply failure
Attempt to deploy your container manually
You have a running server. The next logical thought is to SSH in and start running containers. Let's try that and see what happens.
- SSH into your new EC2 instance by running this command (replace the IP with the value Terraform printed):
ssh -i devops-key ubuntu@[[EC2_IP="the public IP from terraform apply output"]]
What if SSH asks about fingerprints?
The first time you connect, SSH asks if you trust this host. Type yes and press Enter.
You should now see a shell prompt on the remote server. Do you think kubectl is available here?
- Try running the Kubernetes node check:
kubectl get nodes
The command fails. You will see an error telling you that kubectl is not found.
Why did this fail?
A fresh EC2 instance is a blank Ubuntu server. It has no container runtime, no Kubernetes, no way to pull or orchestrate your Docker image. You would need to SSH in and manually install everything.
That manual approach does not scale. If you tore this server down and created a new one, you would have to repeat every command. This is exactly the problem Ansible solves in the next step.
- Exit the SSH session by running:
exit
You are back on your local machine.
Your server is live but empty. Next up, you will write an Ansible playbook that installs K3s on this instance automatically, turning it into a working Kubernetes cluster without a single manual SSH command.
Configure K3s with Ansible
In the last step, you proved that your EC2 instance is running. You also discovered the gap: the server is completely bare, with no Kubernetes installed.
Manually SSHing in and running install commands does not scale. If you tear down the server and recreate it, you would have to repeat every command by hand. Ansible solves this by letting you define the desired state of your server in a playbook that runs over SSH automatically.
In this step, get ready to:
- Create an Ansible inventory file and playbook.
- Run the playbook to install K3s on your EC2 instance.
- Verify the Kubernetes cluster is running.
Create the Ansible inventory file
Ansible needs to know which server to connect to and how to authenticate. An inventory file provides this information in a structured format.
- Create a file called inventory.ini in your project directory by pasting this content:
[k3s_server]
server ansible_host=[[EC2_IP="your EC2 public IP from terraform output"]] ansible_user=ubuntu ansible_ssh_private_key_file=./devops-key ansible_ssh_common_args='-o StrictHostKeyChecking=no'
What does this file do?
The [k3s_server] line defines a group name. Ansible uses this name to match hosts to playbook tasks.
The second line tells Ansible the server's IP address, that the SSH user is ubuntu, which private key to use, and to skip the host key check so the connection does not hang waiting for manual confirmation.
Create the Ansible playbook
A playbook is a YAML file that defines a sequence of tasks Ansible runs on the target server. Your playbook will install K3s (a lightweight Kubernetes distribution) and confirm the cluster is healthy.
- Create a file called playbook.yml in your project directory by pasting this content:
---
- name: Configure K3s on EC2 instance
hosts: k3s_server
become: true
tasks:
- name: Wait for SSH to become available
ansible.builtin.wait_for_connection:
timeout: 120
- name: Install K3s
ansible.builtin.shell: curl -sfL https://get.k3s.io | sh -
args:
creates: /usr/local/bin/k3s
- name: Wait for K3s to be ready
ansible.builtin.shell: kubectl get nodes
register: result
until: result.rc == 0
retries: 10
delay: 10
- name: Verify K3s is running
ansible.builtin.shell: kubectl get nodes
register: nodes_output
- name: Show cluster status
ansible.builtin.debug:
var: nodes_output.stdout_lines
What does this playbook do?
The playbook runs five tasks in order on your EC2 instance:
- Wait for SSH pauses until the server accepts SSH connections (up to 120 seconds).
- Install K3s downloads and runs the official K3s install script. The creates: /usr/local/bin/k3s line makes this idempotent. If K3s is already installed, the task is skipped.
- Wait for K3s to be ready retries kubectl get nodes up to 10 times (with 10-second delays) until the cluster responds.
- Verify and Show captures the node list and prints it to your terminal so you can see the result.
The become: true line runs all tasks as root, which K3s requires for installation.
- Save both inventory.ini and playbook.yml.
- Confirm both files exist by running:
ls inventory.ini playbook.yml
You should see both filenames listed in the output.
Don't see one of the files?
Make sure you created both files in the same project directory where your main.tf and devops-key files live. Run ls to check your current directory contents.
help me find where my Ansible files should go in my project directory
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before continuing.
ⓧ I'd like to double check the full code
Here is inventory.ini:
[k3s_server]
server ansible_host=REPLACE_WITH_EC2_IP ansible_user=ubuntu ansible_ssh_private_key_file=./devops-key ansible_ssh_common_args='-o StrictHostKeyChecking=no'
Here is playbook.yml:
---
- name: Configure K3s on EC2 instance
hosts: k3s_server
become: true
tasks:
- name: Wait for SSH to become available
ansible.builtin.wait_for_connection:
timeout: 120
- name: Install K3s
ansible.builtin.shell: curl -sfL https://get.k3s.io | sh -
args:
creates: /usr/local/bin/k3s
- name: Wait for K3s to be ready
ansible.builtin.shell: kubectl get nodes
register: result
until: result.rc == 0
retries: 10
delay: 10
- name: Verify K3s is running
ansible.builtin.shell: kubectl get nodes
register: nodes_output
- name: Show cluster status
ansible.builtin.debug:
var: nodes_output.stdout_lines
Run the playbook to install K3s
Everything is defined in code. One command will SSH into your server, install K3s, and confirm the cluster is healthy.
- Run the Ansible playbook by executing this command:
ansible-playbook -i inventory.ini playbook.yml
What does this command do?
The -i inventory.ini flag tells Ansible which hosts to target. The playbook.yml argument is the file containing your tasks. Ansible connects over SSH and executes each task in sequence on the remote server.
What should I see?
The playbook takes 1-2 minutes to complete. You will see each task print its status (ok, changed, or skipped). At the end, the Show cluster status task prints a debug output showing your node name and its status.
The final PLAY RECAP line should show failed=0. This means every task succeeded.
Seeing a connection timeout or unreachable error?
Check that the IP address in inventory.ini matches the Terraform output from the previous step. You can re-check it by running terraform output server_public_ip in your project directory.
Also confirm that devops-key has the correct permissions. Run chmod 600 devops-key if you see a "permissions too open" warning.
help me debug why Ansible cannot connect to my EC2 instance
Now verify the cluster directly. Before you run this next command, what do you expect to see when you ask the server for its Kubernetes nodes?
- Verify K3s is running on the server by running:
ssh -i devops-key ubuntu@[[EC2_IP="your EC2 public IP"]] "sudo kubectl get nodes"
You should see a single node with a status of Ready. This confirms your Ansible playbook installed K3s and the cluster is healthy.
Node showing NotReady?
K3s can take a minute to fully initialize after install. Wait 30 seconds and run the SSH command again. If it persists, SSH into the server and check the K3s service status with sudo systemctl status k3s.
help me debug why my K3s node is showing NotReady
Why Ansible instead of a bash script?
You could achieve the same result with a bash script that SSHes in and runs commands. Ansible adds two advantages: idempotency (running the playbook twice does not reinstall K3s) and declarative task structure (each task is named, retryable, and produces clear output).
In a real team, this means anyone can read the playbook and understand what the server should look like without tracing through imperative logic.
Your server now has a fully functional Kubernetes cluster running on it, configured entirely through code. Next up, you will deploy your Docker container to this cluster and see your app live on the internet.
Deploy Your Container to Kubernetes
Your K3s cluster is running and ready to accept workloads. In the last step, Ansible configured your server with a fully functional Kubernetes cluster. Now it is time to put something on it.
This step connects every piece of the pipeline together. The Docker image you pushed to Docker Hub in Step 2 is now pulled and orchestrated by the Kubernetes cluster you provisioned with Terraform and configured with Ansible. You will write a manifest file that declares what your app should look like when running, then apply it to the cluster.
In this step, get ready to:
- Create a Kubernetes deployment manifest defining your app and a NodePort Service.
- Apply the manifest to your cluster and verify the app is live on the internet.
Create the Kubernetes deployment manifest
Kubernetes uses YAML manifest files to declare what resources should exist in the cluster. You will define two resources: a Deployment (which tells Kubernetes how many copies of your container to run) and a Service (which exposes those containers to the network).
- Create a k8s directory in your 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.
- Create k8s/deployment.yaml in VS Code and add the Deployment resource by pasting the following code:
apiVersion: apps/v1
kind: Deployment
metadata:
name: devops-app
spec:
replicas: 2
selector:
matchLabels:
app: devops-app
template:
metadata:
labels:
app: devops-app
spec:
containers:
- name: devops-app
image: [[DOCKERHUB_USER="your Docker Hub username"]]/devops-app:v1
ports:
- containerPort: 3000
What does this Deployment do?
- replicas: 2 tells Kubernetes to run two copies of your container. If one crashes, Kubernetes automatically replaces it.
- selector and labels connect the Deployment to the pods it manages. They must match.
- image points to the Docker Hub image you pushed in Step 2. Kubernetes pulls it from the registry.
- containerPort: 3000 declares which port the container listens on.
- Add a YAML document separator and the Service resource below the Deployment. Paste this at the bottom of the same file:
---
apiVersion: v1
kind: Service
metadata:
name: devops-app-service
spec:
type: NodePort
selector:
app: devops-app
ports:
- port: 3000
targetPort: 3000
nodePort: 30000
What does this Service do?
A Kubernetes Service gives your pods a stable network endpoint. The NodePort type opens a port on the host machine itself, so you can reach your app from the public internet.
- nodePort: 30000 is the port on the EC2 instance that forwards traffic to your containers.
- targetPort: 3000 matches the port your Express app listens on inside the container.
- The selector links this Service to any pod labeled app: devops-app.
- Save k8s/deployment.yaml.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Your k8s/deployment.yaml should look exactly like this:
apiVersion: apps/v1
kind: Deployment
metadata:
name: devops-app
spec:
replicas: 2
selector:
matchLabels:
app: devops-app
template:
metadata:
labels:
app: devops-app
spec:
containers:
- name: devops-app
image: [[DOCKERHUB_USER="your Docker Hub username"]]/devops-app:v1
ports:
- containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
name: devops-app-service
spec:
type: NodePort
selector:
app: devops-app
ports:
- port: 3000
targetPort: 3000
nodePort: 30000
Apply the manifest to your cluster
Your manifest is ready locally, but it needs to reach the Kubernetes API running on your EC2 instance. You will pipe it over SSH directly into kubectl apply.
- Apply the manifest to your cluster by running this command:
ssh -i devops-key ubuntu@[[EC2_IP="your EC2 public IP"]] "sudo kubectl apply -f -" < k8s/deployment.yaml
What does this command do?
- ssh -i devops-key ubuntu@<ip> connects to your EC2 instance using the SSH key you generated in Step 3.
- sudo kubectl apply -f - tells Kubernetes to read a manifest from standard input and create the resources it describes.
- < k8s/deployment.yaml redirects your local file into the remote command. This avoids copying the file to the server first.
You should see output confirming both resources were created:
deployment.apps/devops-app created
service/devops-app-service created
Seeing a connection refused error?
- Make sure you are using the correct EC2 public IP. Run terraform output server_public_ip to double check it.
- Confirm your devops-key file is in your current directory.
- If you see a permissions error on the key file, run chmod 400 devops-key and try again.
Still stuck? Help me troubleshoot my SSH connection to EC2
- Check that your pods are running by running this command:
ssh -i devops-key ubuntu@[[EC2_IP="your EC2 public IP"]] "sudo kubectl get pods"
You should see two pods with a Running status. It may take 30-60 seconds for Kubernetes to pull the image and start the containers. If they show ContainerCreating, wait a moment and run the command again.
Pods stuck in ImagePullBackOff?
- This means Kubernetes cannot pull your image from Docker Hub. Verify that your image name in k8s/deployment.yaml matches exactly what you pushed (including your Docker Hub username and the :v1 tag).
- Check your Docker Hub repository page to confirm the image exists and is public.
Need more help? Help me fix ImagePullBackOff in my Kubernetes pods
Verify your app is live on the internet
This is the moment everything comes together. Your container image from Docker Hub is now running inside Kubernetes on an AWS server you provisioned with code.
- Before you run the next command, predict: do you think you will get a response back from port 30000 on your EC2 instance? Why or why not?
- Test the full pipeline end-to-end by running this command from your local machine:
curl http://[[EC2_IP="your EC2 public IP"]]:30000
You should see a JSON response like this:
{
"service": "devops-app",
"status": "running",
"timestamp": "2026-07-28T12:00:00.000Z",
"host": "devops-app-abc12"
}
What do these fields tell you?
- service confirms which app responded.
- status confirms the Express server is healthy.
- host shows the Kubernetes pod name that handled the request. Run the curl command again and you may see a different hostname. That is Kubernetes load balancing across your two replicas.
Getting connection timed out?
- Verify your security group allows inbound traffic on port 30000. Run terraform output server_public_ip and confirm you are curling the right IP.
- Confirm your pods are in Running state. If they are not ready yet, the Service has no backends to route to.
- Check that your EC2 instance's security group was created by Terraform (it opens ports 30000-32767 for NodePort traffic).
Still stuck? Help me debug why my curl request to the EC2 NodePort is timing out
Your full DevOps pipeline is complete. You wrote code, containerized it, pushed it to a registry, provisioned a server with Terraform, configured it with Ansible, and deployed your app to Kubernetes. Next up, the Secret Mission challenges you to extend this cluster with a second microservice.
Secret mission
Add a Second Microservice with Load Balancing
Real production systems run more than one container. Can you add a second microservice to your cluster and configure Kubernetes to route traffic between them based on URL path?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project provisions an EC2 instance on AWS, which incurs charges if your account is beyond the 12-month free tier window.
Cost warning
If your AWS account is older than 12 months, the t3.micro instance is not covered by the free tier. It costs approximately $0.01/hour while running. Run terraform destroy as soon as you finish to avoid charges.
Resources you used:
- 1x EC2 instance (t3.micro) in us-east-1.
- 1x Security Group (devops-sg).
- 1x Key Pair (devops-key).
- Docker Hub images (devops-app:v1 and health-service:v1).
- Local project directory on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building on your cluster or plan to continue shortly.
- Your app stays live at the EC2 public IP.
- Monitor your AWS billing dashboard if your account is beyond the free tier.
- Docker Hub images remain available for anyone to pull.
Pause - I'll come back to this later
Stop the EC2 instance to halt compute charges while keeping your files and configuration intact. A small EBS storage charge remains while stopped.
- Open the EC2 console at https://console.aws.amazon.com/ec2/.
- In the left navigation pane, choose Instances.
- Select your devops-k3s-server instance.
- Choose Instance state, then Stop instance.
- Select Stop when prompted to confirm.
When you are ready to resume, select the instance again and choose Instance state, then Start instance. Note that the public IP will change after restart, so update your inventory.ini file before re-running Ansible.
Delete - I don't want to use this again
Remove all AWS resources and local project files to start fresh. This is irreversible.
Destroy all AWS resources by running this command from your project directory:
- Open your terminal and navigate to your project folder on the Desktop.
- Run the following command:
terraform destroy
- When prompted, type yes and press Enter to confirm.
- Wait for Terraform to report that all resources have been destroyed.
Delete your Docker Hub repositories:
- Sign in to Docker Hub at https://hub.docker.com.
- Select My Hub, then Repositories.
- Select the devops-app repository.
- Select the Settings tab.
- Select Delete repository, enter the repository name to confirm, then select Delete Repository Forever.
- Repeat for the health-service repository.
Delete your local project files:
- Remove the project directory by running:
cd ~/Desktop
rm -rf devops-app
Uninstall tools (optional):
- If you no longer need the tools installed in this project, remove them with Homebrew:
brew uninstall hashicorp/tap/terraform
brew uninstall ansible
brew uninstall --cask docker
- The AWS CLI was installed via the macOS package installer. To remove it, delete the following paths:
sudo rm -rf /usr/local/aws-cli
sudo rm /usr/local/bin/aws
sudo rm /usr/local/bin/aws_completer
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a containerized application to a Kubernetes cluster on AWS, with every piece of infrastructure defined and automated through code.
You've learned how to:
- Built a Docker container image and pushed it to Docker Hub so any cluster can pull and run your application.
- Wrote Terraform configuration that provisions an EC2 instance with networking and security rules, and tears it all down with a single command.
- Created an Ansible playbook that installs and configures K3s on a remote server over SSH with zero manual commands.
- Secret Mission: Extended the cluster with a second microservice and configured path-based routing through a Kubernetes Ingress resource.
Ready to quiz yourself?