Deploy a Containerized App with Terraform

Use Terraform and GitHub Actions to deploy a Flask app in Docker.

Introduction

30 Second Summary

Every time a developer pushes code, someone (or something) needs to package it up, deploy it, and make sure everything is running smoothly. In the real world, this is handled by automated pipelines and infrastructure defined as code.

In this project, you will build a complete DevOps pipeline that deploys a Python web app as a Docker container using Terraform, then automate image builds with GitHub Actions.

What You'll Build

A local DevOps pipeline that uses Terraform to manage a containerized Flask web app and a CI/CD workflow that automatically builds and pushes Docker images to Docker Hub.

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

  • A working Terraform configuration that builds and deploys a Docker container to your local machine.
  • A GitHub Actions CI/CD pipeline that automatically builds and pushes your Docker image on every code push.
  • Hands-on experience with the full Infrastructure as Code workflow: terraform init, plan, apply, and destroy.
  • Secret Mission: Add a second container with an Nginx reverse proxy managed entirely by Terraform.

Are there any prerequisites?

No prior DevOps experience is required. This project walks you through installing every tool from scratch, including Terraform, Docker Desktop, and Git. You just need a Mac and an internet connection.

Install Terraform

Every resource in this project will be managed by Terraform. Terraform is the industry-standard tool for writing infrastructure as code.

In this step, you will install Terraform on your Mac and create the project folder where all your files will live.

In this step, get ready to:
  • Install Terraform using Homebrew.
  • Create a project directory with a Git repository.
Open your terminal
  • Press Cmd+Space to open Spotlight search.
  • Type Terminal and press Enter to open it.
Check for Homebrew

Terraform is installed through Homebrew, the package manager for macOS. First, confirm that Homebrew is available on your machine.

  • Check if Homebrew is installed by running this command:
brew --version

✔️ I see a version number

Homebrew is ready. Move on to the next section to install Terraform.

ⓧ Command not found

Homebrew is not installed yet. Install it by running this command:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

What does this command do?

This downloads and runs the official Homebrew installer script from brew.sh. The script will explain what it plans to do and ask for your confirmation before making any changes.

After installation completes, follow any instructions the installer prints about adding Homebrew to your PATH. Then close and reopen Terminal before continuing.

Install Terraform

Terraform is distributed through HashiCorp's own Homebrew tap. A tap is a third-party repository of Homebrew packages.

  • Check if Terraform is already installed by running:
terraform --version

✔️ I see version 1.5.7 or higher

Terraform is ready. You can skip ahead to the next section where you create your project directory.

ⓧ I see an older version

You have an outdated version of Terraform. Upgrade it by running these commands:

brew tap hashicorp/tap
brew upgrade hashicorp/tap/terraform

After the upgrade completes, verify the new version:

terraform --version

You should see v1.15.x or higher in the output.

ⓧ Command not found

Terraform is not installed yet. Install it by running these two commands:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

What do these commands do?

  • brew tap hashicorp/tap adds HashiCorp's package repository to Homebrew so it knows where to find Terraform.
  • brew install hashicorp/tap/terraform downloads and installs the latest version of Terraform from that repository.

After installation completes, verify it worked by running:

terraform --version

You should see v1.15.x in the output. This confirms Terraform is installed and available in your terminal.

Still seeing command not found after installing?

  • Close your Terminal completely and reopen it. New installations sometimes require a fresh shell session.
  • Make sure the Homebrew install completed without errors. Look for red error text in the output above.

Help me troubleshoot my Terraform installation on macOS.

Create your project directory

Now that Terraform is installed, create a folder for your project files. You will also initialize a Git repository so you can track changes and push to GitHub later.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir flask-terraform-app
cd flask-terraform-app
  • Initialize a Git repository by running:
git init
  • Confirm everything is set up by running:
ls -la

You should see a hidden .git folder in the output. This means Git is tracking your project.

Don't see a .git folder?

  • Make sure you ran git init from inside the flask-terraform-app folder, not from ~/Desktop directly.
  • Run pwd to confirm your current location. It should end with flask-terraform-app.

Help me set up my Git repository in the right folder.

Your tools are locked and loaded. Next up, you will write the Flask application and Dockerfile that Terraform will deploy.

Write the Flask App and Dockerfile

With Terraform installed and your project directory ready, you need something to deploy. Terraform manages infrastructure, but it needs an application to work with.

In this step, you will create a simple Flask web app and package it into a Docker image using a Dockerfile. This gives Terraform a container it can build and run on your machine.

In this step, get ready to:
  • Create the Flask web application and its dependency file.
  • Write a Dockerfile to package the app into a container image.
  • Add a .gitignore to keep Terraform state files out of Git.
Create the Flask application

Flask is a lightweight Python web framework. Your app only needs two routes: a home page that returns a JSON welcome message, and a health check endpoint.

  • Create a file called app.py in your flask-terraform-app folder and add the following code:
from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify({
        "message": "Hello from Terraform-deployed container!",
        "status": "running",
        "version": "1.0.0"
    })

@app.route("/health")
def health():
    return jsonify({"status": "healthy"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

This file defines a minimal web server with two endpoints:

  • The / route returns a JSON object with a welcome message, status, and version number.
  • The /health route returns a simple health check response that monitoring tools can ping.
  • The host="0.0.0.0" setting makes the app accessible from outside the container, not just from within it.
  • Save app.py.
  • Now create a file called requirements.txt in the same folder with this single line:
flask==3.1.1

Why pin the Flask version?

Pinning flask==3.1.1 ensures every build uses the exact same version. Without a pin, a newer Flask release could introduce breaking changes and silently break your container.

  • Save requirements.txt.
  • Confirm both files exist by running:
ls
  • You should see app.py and requirements.txt in the output.

Don't see your files?

  • Make sure you saved each file inside the flask-terraform-app folder, not a parent or child directory.
  • Check that the filenames are exactly app.py and requirements.txt with no extra extensions like .txt.txt.

Why aren't my files showing up in ls?

Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to package your app into a portable container image. It specifies the base operating system, installs dependencies, copies your code, and defines the startup command.

  • Create a file called Dockerfile (no extension) in your flask-terraform-app folder with this content:
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a lightweight Python image so the container stays small.
  • WORKDIR /app sets the working directory inside the container.
  • COPY requirements.txt . and RUN pip install install Flask before copying the app code. This layer caching means rebuilds are faster when only your code changes.
  • CMD ["python", "app.py"] tells Docker what command to run when the container starts.
  • Save Dockerfile.
  • Confirm it exists by running:
ls
  • You should see Dockerfile alongside app.py and requirements.txt.

Dockerfile not appearing?

  • The file must be named exactly Dockerfile with a capital D and no file extension.
  • Some editors automatically add a .txt extension. If you see Dockerfile.txt, rename it to remove the extension.

Help me fix my Dockerfile naming issue.

Add a .gitignore file

Terraform generates local state files and a plugin cache that should never be committed to Git. A .gitignore file tells Git to skip these files automatically.

  • Create a file called .gitignore in your flask-terraform-app folder with this content:
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
__pycache__/

What are these files being ignored?

  • .terraform/ is the plugin cache directory created by terraform init.
  • *.tfstate files contain your infrastructure's current state. They can include sensitive data and should stay local.
  • __pycache__/ is Python's bytecode cache. It is machine-specific and not needed in version control.
  • Save .gitignore.
  • Confirm the file exists by running:
ls -a
  • You should see .gitignore in the output. The -a flag shows hidden files (those starting with a dot).

Don't see .gitignore in ls output?

  • You must use ls -a instead of plain ls. Files starting with a dot are hidden by default on macOS.
  • Make sure the filename starts with a dot: .gitignore, not gitignore.

Why isn't my .gitignore file appearing?

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files before moving on.

ⓧ I'd like to double check the full code

Here is every file you should have in your flask-terraform-app folder at this point.

from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify({
        "message": "Hello from Terraform-deployed container!",
        "status": "running",
        "version": "1.0.0"
    })

@app.route("/health")
def health():
    return jsonify({"status": "healthy"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
flask==3.1.1
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
__pycache__/

Your application code is ready. Next up, you will write the Terraform configuration that tells Terraform how to build and deploy this container.

Write Terraform Configuration

Your Flask app and Dockerfile are ready to be packaged into a container. Now you need to tell Terraform exactly what infrastructure to create.

Terraform reads .tf configuration files to understand what resources you want. In this step, you will write the configuration that tells Terraform to build your Docker image and run it as a container.

In this step, get ready to:
  • Create main.tf with the Docker provider, image, and container resources.
  • Create outputs.tf to display useful info after deployment.
  • Run terraform init to download the Docker provider plugin.
Create main.tf with the Docker provider

Terraform configuration starts with declaring which providers you need. A provider is a plugin that lets Terraform talk to a specific platform. You will use the kreuzwerker/docker provider to manage Docker resources on your local machine.

  • Create a new file called main.tf in your project folder and add the following code:
# Declare the required provider and Terraform version
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.4.0"
    }
  }
  required_version = ">= 1.5.7"
}

# Connect Terraform to your local Docker engine
provider "docker" {
  host = "unix:///var/run/docker.sock"
}

What does this code do?

The terraform block pins the Docker provider to version ~> 4.4.0 (any 4.4.x release) and requires Terraform >= 1.5.7. This prevents breaking changes from unexpected upgrades.

The provider block tells Terraform how to connect to Docker. The host value points to the Docker socket on your Mac, which is how any tool communicates with Docker Desktop.

You won't see a change yet. The next chunk defines the image resource that uses this provider.

  • Add the Docker image resource below the provider block in main.tf:
# Build the Flask app into a Docker image
resource "docker_image" "flask_app" {
  name = "flask-terraform-app:latest"

  build {
    context    = path.cwd
    dockerfile = "Dockerfile"
  }

  # Rebuild the image when source files change
  triggers = {
    app_hash        = filesha256("${path.cwd}/app.py")
    dockerfile_hash = filesha256("${path.cwd}/Dockerfile")
    requirements_hash = filesha256("${path.cwd}/requirements.txt")
  }
}

What does this code do?

The docker_image resource tells Terraform to build a Docker image from your current directory using the Dockerfile you wrote in the previous step.

The triggers block computes a hash of each source file. If you change app.py, the hash changes, and Terraform knows it needs to rebuild the image on the next apply.

Check your Docker Desktop settings

The build block requires a specific Docker Desktop setting to be disabled. Open Docker Desktop and click the gear icon to open Settings.

Go to the General tab and make sure Use containerd for pulling and storing images is unchecked. If it was enabled, uncheck it and click Apply & restart. Docker Desktop will restart before you continue.

You won't see a change yet. The next chunk defines the container that runs this image.

  • Add the Docker container resource below the image resource in main.tf:
# Run the Flask app as a container
resource "docker_container" "flask_app" {
  name  = "flask-app"
  image = docker_image.flask_app.image_id

  # Map container port 5000 to host port 8080
  ports {
    internal = 5000
    external = 8080
  }

  restart = "unless-stopped"
}

What does this code do?

The docker_container resource creates a running container from the image Terraform just built. It maps port 5000 inside the container to port 8080 on your Mac.

The image attribute references docker_image.flask_app.image_id. This is how Terraform resources connect to each other. Terraform knows it must build the image before starting the container.

  • Save main.tf.
  • Confirm the file exists by running:
ls main.tf

You should see main.tf listed in the output.

Don't see main.tf?

Make sure you saved the file in your project folder, not a parent or child directory. Run pwd to confirm you are in the correct directory.

help me find where my main.tf file was saved

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before continuing.

ⓧ I'd like to double check the full code

terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.4.0"
    }
  }
  required_version = ">= 1.5.7"
}

provider "docker" {
  host = "unix:///var/run/docker.sock"
}

resource "docker_image" "flask_app" {
  name = "flask-terraform-app:latest"

  build {
    context    = path.cwd
    dockerfile = "Dockerfile"
  }

  triggers = {
    app_hash        = filesha256("${path.cwd}/app.py")
    dockerfile_hash = filesha256("${path.cwd}/Dockerfile")
    requirements_hash = filesha256("${path.cwd}/requirements.txt")
  }
}

resource "docker_container" "flask_app" {
  name  = "flask-app"
  image = docker_image.flask_app.image_id

  ports {
    internal = 5000
    external = 8080
  }

  restart = "unless-stopped"
}
Create outputs.tf

Terraform can display useful information after it finishes deploying. You define these in an outputs file so you always know how to access your running app.

  • Create a new file called outputs.tf in your project folder and add the following code:
# Display the container name after deployment
output "container_name" {
  value       = docker_container.flask_app.name
  description = "Name of the running container"
}

# Display the URL to access the app
output "app_url" {
  value       = "http://localhost:8080"
  description = "URL to access the Flask app"
}

What does this code do?

Each output block defines a value that Terraform prints to your terminal after a successful apply. You get the container name and the URL to visit your app without having to remember them.

  • Save outputs.tf.
  • Confirm both Terraform files exist by running:
ls *.tf

You should see main.tf and outputs.tf in the output.

Only see one file?

Check that both files are saved in the same project folder. Run pwd to verify your current directory, then ls to see all files.

help me find my missing Terraform file

✔️ Awesome, I've got everything!

Great. Double check you have saved outputs.tf before continuing.

ⓧ I'd like to double check the full code

output "container_name" {
  value       = docker_container.flask_app.name
  description = "Name of the running container"
}

output "app_url" {
  value       = "http://localhost:8080"
  description = "URL to access the Flask app"
}
Run terraform init to download the provider

Before Terraform can manage any resources, it needs to download the provider plugin you declared in main.tf. The terraform init command handles this setup.

  • Initialize your Terraform project by running:
terraform init

You should see output ending with Terraform has been successfully initialized! and a note about running terraform plan next.

What does terraform init do?

This command reads your required_providers block, downloads the kreuzwerker/docker provider plugin from the Terraform Registry, and stores it in a hidden .terraform/ directory. Your .gitignore already excludes this directory from version control.

Seeing an error during init?

  • If you see a network error, check your internet connection. Terraform needs to download the provider from the registry.
  • If you see "No configuration files", make sure your terminal is in the same folder as main.tf. Run ls main.tf to verify.
  • If you see a version constraint error, confirm your Terraform version with terraform --version. You need 1.5.7 or higher.

help me fix my terraform init error

Your Terraform configuration is locked and loaded. Next up, you will run terraform plan and terraform apply to bring your container to life.

Deploy with Terraform

Your Terraform configuration is written and initialized. The provider is downloaded, and Terraform knows how to talk to your local Docker engine.

Now it's time to actually deploy. Terraform's workflow has a built-in safety net: before it changes anything, it shows you a preview of exactly what it plans to do. You'll review the plan, approve it, and watch Terraform build and launch your container.

In this step, get ready to:
  • Preview infrastructure changes with terraform plan.
  • Deploy your container with terraform apply.
  • Verify the Flask app is accessible in your browser.
Preview what Terraform will create

The terraform plan command reads your configuration files and compares them against the current state. Since this is a fresh project with no existing resources, it will show everything as "to be created."

  • Preview what Terraform will create by running this command:
terraform plan

Terraform displays a detailed breakdown of the two resources it will create. At the bottom, you should see a summary line:

Plan: 2 to add, 0 to change, 0 to destroy.

What does this output mean?

Terraform prefixes each resource with a + symbol to show it will be created. The two resources are docker_image.flask_app (your container image) and docker_container.flask_app (the running container).

The plan is read-only. Nothing has been created yet. Think of it as a dry run that lets you catch mistakes before they happen.

Seeing an error instead of a plan?

  • Make sure Docker Desktop is running. Terraform needs the Docker daemon to validate the configuration.
  • Check that you are inside your project directory (the folder containing main.tf).
  • If you see a Docker socket error, verify that "Use containerd for pulling and storing images" is disabled in Docker Desktop settings.

Still stuck? Help me debug my terraform plan error

Deploy the container with terraform apply

The plan looks good. Now you'll tell Terraform to execute it. This will build your Docker image from the Dockerfile and start a container with port 8080 mapped to the Flask app.

  • Deploy your infrastructure by running this command:
terraform apply

What happens when you run this?

Terraform shows the same plan you previewed earlier, then asks for confirmation. You'll see this prompt:

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:

This is the safety gate. Terraform will not create anything until you explicitly approve.

  • Type yes and press Enter to confirm the deployment.

Terraform builds your Docker image and starts the container. This may take 30-60 seconds the first time because it downloads the python:3.12-slim base image. When it finishes, you should see:

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

app_url = "http://localhost:8080"
container_name = "flask-app"

What is Terraform state?

After applying, Terraform creates a terraform.tfstate file in your project directory. This file records exactly what Terraform created so it can track, update, and destroy those resources later.

Your .gitignore already excludes state files from version control. State files can contain sensitive data and should never be committed.

Apply failed or container not starting?

  • If you see a port conflict error, another process is using port 8080. Stop it or change the external port in main.tf.
  • If Docker reports a build error, double-check that app.py, requirements.txt, and Dockerfile are all saved in the same directory as main.tf.
  • If you accidentally typed something other than yes at the prompt, Terraform cancels the operation. Run terraform apply again.

Need more help? Help me debug my terraform apply failure

Verify the running app in your browser

The outputs confirm your container is running and accessible on port 8080. Time to see it for real.

  • Open your browser and navigate to http://localhost:8080.

You should see a JSON response like this:

{
  "message": "Hello from Terraform-deployed container!",
  "status": "running",
  "version": "1.0.0"
}

What just happened?

Terraform built a Docker image from your Dockerfile, started a container named flask-app, and mapped port 8080 on your machine to port 5000 inside the container where Flask is listening.

The entire container lifecycle is now managed by Terraform. If you want to tear it all down later, a single terraform destroy command removes both the container and the image. That's the power of Infrastructure as Code.

Browser showing "connection refused"?

  • Make sure you're using http:// and not https://. Flask is not running with SSL.
  • Confirm the container is actually running by opening Docker Desktop and checking the Containers view. You should see flask-app with status "Running".

Still can't connect? Help me figure out why my Flask container isn't reachable

Your Flask app is live, deployed entirely through code. Next up, you'll automate the Docker image build with a CI/CD pipeline so every code change triggers a fresh build automatically.

Set Up GitHub Actions CI/CD

Your Flask app is running locally via Terraform. That is great for development, but in a real team environment, you want new code changes to automatically build a fresh Docker image and push it to a registry.

This is what a CI/CD pipeline does. In this step, you will create a GitHub Actions workflow that builds and pushes your Docker image to Docker Hub every time you push code to your repository.

In this step, get ready to:
  • Create a GitHub Actions workflow file.
  • Push your code to a new public GitHub repository.
  • Add Docker Hub credentials as repository secrets.
Create the workflow file

GitHub Actions workflows live in a special directory called .github/workflows/ inside your project. GitHub automatically detects YAML files in this folder and runs them based on the triggers you define.

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

You should see build-push.yml listed in the output.

  • Open build-push.yml in your text editor and paste the following workflow configuration:
name: Build and Push Docker Image

on:
  push:
    branches: [main]

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/flask-terraform-app:latest,${{ secrets.DOCKERHUB_USERNAME }}/flask-terraform-app:${{ github.sha }}

What does this workflow do?

  • on: push: branches: [main] triggers the workflow every time you push to the main branch.
  • actions/checkout@v4 pulls your repository code into the runner so the build has access to your Dockerfile.
  • docker/login-action@v3 authenticates to Docker Hub using secrets you will configure shortly.
  • docker/build-push-action@v6 builds the image from your Dockerfile and pushes it to Docker Hub with two tags: latest and the unique commit SHA.
  • Save build-push.yml.

File not saving correctly?

YAML is sensitive to indentation. Make sure you are using spaces (not tabs) and that each level is indented by exactly 2 spaces. If your editor has a YAML mode, enable it for automatic formatting.

help me fix YAML indentation issues in my GitHub Actions workflow file

Push your code to a new public GitHub repository

Your workflow file is ready, but GitHub Actions only runs on code that lives on GitHub. You need to push your local project to a new public repository.

  • Go to github.com/new in your browser.
  • Enter flask-terraform-app as the repository name.
  • Select Public visibility.
  • Leave all checkboxes unchecked (do not add a README, .gitignore, or license).
  • Click Create repository.

Why leave the repository empty?

You already have a local Git repository with commits. Initializing the remote with files would create a conflict when you try to push. Starting empty avoids merge conflicts entirely.

GitHub will show a Quick Setup page with your repository URL. Copy that URL, then return to your terminal.

  • Stage all your project files and commit them by running:
git add .
git commit -m "Initial commit with Flask app, Terraform config, and CI/CD workflow"
  • Connect your local repository to GitHub and push your code by running:
git remote add origin [[REPO_URL="https://github.com/YOUR-USERNAME/flask-terraform-app.git"]]
git push -u origin main

What if my branch is called master?

If git push -u origin main fails because your branch is named master, rename it first by running git branch -M main and then push again.

  • Refresh your GitHub repository page in the browser.

You should see all your project files listed, including the .github/workflows/ directory.

Push rejected or authentication error?

GitHub requires a Personal Access Token or SSH key for authentication. If you are prompted for a password, use a GitHub Personal Access Token instead. Password-based authentication has been removed.

help me authenticate with GitHub from the terminal so I can push my code

Add Docker Hub credentials as GitHub repository secrets

Your workflow references two secrets: DOCKERHUB_USERNAME and DOCKERHUB_TOKEN. GitHub Actions uses these to log in to Docker Hub during the build. You need to create a Docker Hub access token and store both values as secrets in your GitHub repository.

First, generate a Docker Hub Personal Access Token:

  • Go to Docker Hub and sign in.
  • Select your avatar in the top-right corner, then select Account settings.
  • Select Personal access tokens.
  • Select Generate new token.
  • Enter a description like github-actions-ci and set access permissions to Read & Write.
  • Select Generate and copy the token immediately. You will not be able to see it again after leaving this page.

Save this token somewhere safe

Paste the token into a password manager or a temporary note. You will paste it into GitHub in the next step, and Docker Hub will never show it again.

Now add both credentials as secrets in your GitHub repository:

  • Go to your flask-terraform-app repository on GitHub.
  • Click Settings in the top navigation bar.
  • In the left sidebar under Security, click Secrets and variables, then click Actions.
  • Click New repository secret.
  • Enter DOCKERHUB_USERNAME as the name, and paste your Docker Hub username as the value.
  • Click Add secret.
  • Click New repository secret again.
  • Enter DOCKERHUB_TOKEN as the name, and paste the Personal Access Token you just generated as the value.
  • Click Add secret.

You should now see both secrets listed on the Actions secrets page.

Why use secrets instead of hardcoding credentials?

Secrets are encrypted by GitHub and never exposed in logs or workflow output. Hardcoding credentials in your workflow file would expose them to anyone who can view the repository.

Don't see the Settings tab?

The Settings tab only appears if you are the repository owner. Make sure you are viewing your own repository and not a fork.

help me find the Settings tab in my GitHub repository to add secrets

✔️ Awesome, I've got everything!

Great. Make sure you have saved build-push.yml and that both secrets show up on your GitHub secrets page.

ⓧ I'd like to double check the full code

Here is the complete workflow file for comparison:

name: Build and Push Docker Image

on:
  push:
    branches: [main]

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/flask-terraform-app:latest,${{ secrets.DOCKERHUB_USERNAME }}/flask-terraform-app:${{ github.sha }}

Your CI/CD pipeline is wired up and ready. In the next step, you will trigger the workflow by making a code change and watch it build and push your image automatically.

Trigger the Pipeline and Verify

In the previous step, you pushed your code to GitHub and configured the repository secrets. Your CI/CD pipeline is now armed and ready.

Now it's time to close the loop. You'll make a code change, push it, and watch GitHub Actions automatically build and push a new Docker image. Then you'll re-deploy locally with Terraform to prove the full pipeline works end-to-end.

In this step, get ready to:
  • Trigger the GitHub Actions workflow by pushing a code change.
  • Verify the new Docker image on Docker Hub.
  • Re-deploy your updated container locally with Terraform.
Make a code change and push to GitHub

Your workflow triggers on every push to the main branch. To see this in action, you need to make a change, commit it, and push.

  • In app.py, find this line inside the home() function:
"version": "1.0.0"
  • Change 1.0.0 to 1.0.1, so the line reads:
"version": "1.0.1"
  • Save app.py.
  • Commit and push the change by running these commands:
git add app.py
git commit -m "Bump version to 1.0.1"
git push origin main

What do these commands do?

git add app.py stages the file change. git commit saves a snapshot with your message. git push origin main sends the commit to GitHub, which triggers the workflow.

  • You should see output confirming the push was successful, with a line like main -> main.

Push rejected or authentication error?

Make sure you're pushing to the correct remote. Run git remote -v to confirm the URL matches your GitHub repository.

If you see a permission denied error, you may need to re-authenticate. GitHub requires a Personal Access Token or SSH key for push access.

help me fix a git push authentication error

Watch the workflow run on GitHub

The moment your push lands on main, GitHub Actions picks it up and starts building your Docker image. You can watch this happen in real time.

  • Switch back to your GitHub repository in your browser.
  • Click Actions in the top navigation bar (under your repository name).
  • In the left sidebar, click Build and Push Docker Image (your workflow name).
  • Click the most recent workflow run in the list (it will have your commit message as the title).
  • Click the build-and-push job to see the live logs.

What should I see?

Each step in the job expands to show its logs. You'll see the checkout, the Docker Hub login, and then the image build and push. A green checkmark next to the job name means it completed successfully.

The workflow typically takes 1-2 minutes to finish since it builds a fresh Docker image from your Dockerfile.

  • Wait for a green checkmark to appear next to the build-and-push job. This confirms your image was built and pushed to Docker Hub.

Seeing a red X on the workflow?

Click into the failed job and expand the failing step to read the error message.

The most common cause is incorrect repository secrets. Go to your repository Settings then Secrets and variables then Actions, and verify that DOCKERHUB_USERNAME and DOCKERHUB_TOKEN are both present and correct.

help me debug a failing GitHub Actions workflow

Verify on Docker Hub and re-deploy locally

Your image is now on Docker Hub. You can verify it arrived, then use Terraform to rebuild and redeploy your local container with the updated code.

  • Open Docker Hub in your browser (hub.docker.com).
  • Navigate to your flask-terraform-app repository. You should see the latest tag and a tag matching your commit SHA.

Why two tags?

Your workflow pushes two tags for every build: latest (always points to the most recent build) and a commit SHA tag (pinpoints exactly which code produced that image). This is a best practice for traceability in production.

  • Now re-deploy locally. Switch back to your terminal in the project directory.
  • Re-apply your Terraform configuration by running:
terraform apply
  • Terraform will prompt you to confirm. Type yes and press Enter.

What does Terraform detect?

The triggers block in your main.tf hashes app.py, Dockerfile, and requirements.txt. Because you changed app.py, the hash is different. Terraform destroys the old image and container, then rebuilds fresh.

  • After Terraform finishes, verify the updated app by running:
curl http://localhost:8080
  • You should see the JSON response with "version": "1.0.1" in the output, confirming your updated code is running.

Still seeing the old version?

Make sure you saved app.py before running terraform apply. Terraform hashes the file on disk, so unsaved changes won't be picked up.

If Terraform says "No changes" and doesn't rebuild, double-check the triggers block in main.tf includes app_hash = filesha256("${path.cwd}/app.py").

help me figure out why Terraform isn't detecting my code change

You just completed a full Infrastructure as Code plus CI/CD loop. A code change on your machine triggered an automated image build on GitHub, pushed it to a registry, and you redeployed locally with a single Terraform command. Up next: a secret mission to extend your pipeline with multi-container orchestration.

Secret mission

Add an Nginx Reverse Proxy with Terraform

In production, web apps rarely face the internet directly. A reverse proxy like Nginx sits in front, handling routing, load balancing, and security. In this mission, you will add a second Terraform-managed container that proxies traffic to your Flask app.

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 runs entirely locally with free services, so there are no ongoing costs.

Resources you used:

  • Docker containers (flask-app and nginx-proxy) managed by Terraform.
  • Docker network (app-network) managed by Terraform.
  • Docker images (flask-terraform-app:latest and nginx:1.27-alpine) built locally.
  • Docker Hub image (flask-terraform-app repository).
  • GitHub repository (public).
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to extend it further.

  • Your Docker containers will continue running until you stop Docker Desktop or restart your machine.
  • Your GitHub Actions workflow will trigger on every push to the main branch.
  • Terraform remains installed and ready for your next project.

Pause - I'll come back to this later

Shut down running containers to free up system memory while keeping all your files intact.

  • Stop and remove the Terraform-managed containers by running:
terraform destroy
  • Type yes when prompted to confirm.
  • Your project files, Terraform state, and GitHub repository remain untouched for when you return.
  • To resume later, run terraform apply from your project folder to bring everything back up.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Destroy all Terraform-managed resources (containers, network, and images) by running:
terraform destroy
  • Type yes when prompted to confirm.
  • Delete your local project folder by running:
cd ~/Desktop
rm -rf flask-terraform-app
  • Delete your Docker Hub repository:
  • Sign in to Docker Hub.
  • Select My Hub, then Repositories.
  • Click your flask-terraform-app repository.
  • Select the Settings tab.
  • Click Delete repository, enter the repository name to confirm, then click Delete Repository Forever.
  • Delete your GitHub repository:
  • Open your repository on GitHub.
  • Click Settings.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Follow the confirmation prompts, type the repository name, and click Delete this repository.
  • Uninstall Terraform from your machine by running:
brew uninstall hashicorp/tap/terraform

Nice Work!

Nice Work!

You just built a real DevOps pipeline from scratch. You now have a fully working Infrastructure as Code setup that deploys containers, a CI/CD pipeline that automates image builds, and hands-on experience with the tools DevOps engineers use daily.

You learned how to:

  • Written Infrastructure as Code with Terraform to deploy and manage Docker containers using the full init, plan, apply, and destroy workflow.
  • Containerized a Python Flask application with Docker and published the image to Docker Hub.
  • Set up a CI/CD pipeline with GitHub Actions that automatically builds and pushes new Docker images on every code push.
  • Added multi-container orchestration with an Nginx reverse proxy managed by Terraform.

Ready to quiz yourself?