Deploy a Multi-Container Stack with Terraform
Use Terraform to deploy a networked API, PostgreSQL, Redis, and Nginx stack.
Introduction
30 Second Summary
Imagine setting up a server by typing a dozen commands, then needing to do the same thing on a second machine. You can't remember half of what you typed, and nothing is written down.
In this project, you will deploy a multi-container application stack on a Linux server using Terraform. Your API, database, cache, and reverse proxy will all be defined in code files that you can tear down and rebuild with a single command.
What You'll Build
You'll hit a URL on your server and watch a request flow through Nginx to your API, which saves data in PostgreSQL and caches it in Redis. All of it deployed from one terraform apply command and destroyable with one terraform destroy.
By the end of this project, you'll have:
- A running Node.js API that writes to PostgreSQL and caches reads in Redis, proving that containers discover each other by name over a Docker network.
- An Nginx reverse proxy routing external traffic to your API, the same front-door pattern used in production.
- The entire stack defined declaratively in Terraform configuration files. You can destroy it and rebuild it identically, proving your infrastructure is code and not a collection of manual steps.
- Secret Mission: Add a second API service and configure Nginx to route to both by URL path, simulating a real microservices gateway.
Are there any prerequisites?
You need a Windows PC with an SSH client (Windows Terminal or PuTTY) and a physical Linux machine running a fresh Ubuntu Server install. Basic command line familiarity is helpful, but no prior experience with Docker or Terraform is needed.
Before We Start
Before diving into installing tools and writing configuration files, let's lock in what you're about to build and why it matters for your goal of running multiple communicating services.
Set Up Your Linux Server
Your Ubuntu Server is a blank slate. It has no container runtime, no infrastructure-as-code tool, and no version control.
In this step, you will install the three foundational tools that everything else in this project depends on: Docker (to run containers), Terraform (to manage them as code), and Git (to version-control your configuration files).
In this step, get ready to:
- Install Docker Engine from Docker's official apt repository.
- Install Terraform from HashiCorp's official apt repository.
- Install Git and verify all three tools are working.
Install Docker Engine
Docker Engine is the runtime that actually creates and manages containers on your server. Without it, there is nothing to run your application stack in.
- In your SSH session, set up Docker's apt repository by running these commands:
# Install prerequisite packages
sudo apt update
sudo apt install ca-certificates curl
# Create the keyrings directory
sudo install -m 0755 -d /etc/apt/keyrings
# Add Docker's official GPG key
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
What do these commands do?
These commands download Docker's signing key so your system can verify that the packages you install actually come from Docker. The /etc/apt/keyrings directory is where Ubuntu stores trusted GPG keys for third-party repositories.
- Add Docker's repository to your apt sources and refresh the package list by running:
# 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
# Update package list with the new repository
sudo apt update
- Install Docker Engine and its companion packages by running:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
What did you just install?
- docker-ce is the Docker Engine itself (the daemon that runs containers).
- docker-compose-plugin lets you define multi-container apps in a YAML file (you will use Terraform instead, but it is useful to have).
- containerd.io is the low-level container runtime Docker delegates to.
- Add your user to the docker group so you can run Docker commands without sudo:
sudo usermod -aG docker $USER
newgrp docker
Why do I need to join the docker group?
By default, only root can talk to the Docker daemon. Adding your user to the docker group grants that permission. The newgrp docker command activates the group membership in your current session without requiring a full logout.
- Verify Docker is installed and running:
docker --version
You should see a version number printed, confirming Docker is on your PATH and ready to use.
Permission denied or command not found?
- If you see "permission denied", the group change has not taken effect. Try running newgrp docker again, or disconnect your SSH session and reconnect.
- If you see "command not found", the install may have failed. Run sudo apt install docker-ce again and check for error messages.
Help me troubleshoot my Docker installation
Install Terraform
Terraform is the tool that turns your .tf configuration files into real, running infrastructure. It manages the full lifecycle: create, update, and destroy.
- Add HashiCorp's GPG key and apt repository by running these commands:
# Add HashiCorp's GPG key
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
# Add the HashiCorp repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
- Update your package list and install Terraform by running:
sudo apt update && sudo apt install terraform
What is a Terraform provider?
Terraform itself is a general-purpose engine. It does not know how to create Docker containers or AWS resources on its own. Instead, it uses plugins called providers that teach it how to talk to specific platforms.
In this project, you will use the kreuzwerker/docker provider, which tells Terraform how to manage Docker images, containers, and networks through the local Docker socket.
- Verify Terraform is installed by running:
terraform --version
You should see output showing Terraform v1.15.8 (or a newer version). This confirms Terraform is installed and on your PATH.
Seeing 'command not found' for terraform?
- Make sure both the wget and echo commands completed without errors. If the GPG key step failed, the apt install will not find the package.
- Run sudo apt update and look for error lines mentioning "hashicorp". If the repository was not added correctly, re-run the repository command above.
Help me troubleshoot my Terraform installation
Install Git and verify all tools
Git lets you version-control your Terraform files. Any change you make to your infrastructure is tracked, reversible, and shareable.
- Install Git by running:
sudo apt install git
- Verify Git is installed:
git --version
You should see a version number printed.
Before you move on, take a moment to predict: if you run all three version commands in sequence, what do you expect to see?
- Run all three verification commands together to confirm your server is fully set up:
docker --version
terraform --version
git --version
All three commands print version numbers. Your server now has everything it needs to run containers and manage them as code.
One of the tools is not printing a version?
- Go back to the substep for the failing tool and re-run its install commands. Check for error output during the apt install step.
- If Docker specifically says "permission denied", disconnect your SSH session entirely, reconnect, and try docker --version again.
Help me figure out what went wrong with my tool installation
Your server is fully armed. Next up, you will deploy a container the manual way to feel exactly why Terraform exists.
Deploy a Container Manually
Your server is ready with Docker, Terraform, and Git installed. Now you are going to deploy a real container by hand.
You will write a simple Node.js API, package it in a Docker container, and run it manually from the command line. By the end of this step, you will have a working API. But you will also feel exactly why this manual approach falls apart when you need to manage multiple services.
In this step, get ready to:
- Write a minimal Node.js API and a Dockerfile.
- Build and run the container manually with Docker.
- Test the container, tear it down, and reflect on reproducibility.
Create the app directory and application files
Your API needs three files: a package.json defining the project and its dependencies, an index.js file with the server code, and a Dockerfile telling Docker how to package the app.
- Create the app subdirectory inside your infra-project folder by running these commands:
cd ~/infra-project
mkdir app
cd app
- Create the package.json file by running this command:
cat > package.json << 'EOF'
{
"name": "terraform-docker-api",
"version": "1.0.0",
"description": "Simple API for Terraform Docker project",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "4.21.2"
}
}
EOF
What does this file do?
This package.json tells Node.js your project name, entry point, and that it depends on Express version 4.21.2 for handling HTTP requests. The start script tells Docker how to launch the app.
- Create the index.js file containing your API server by running this command:
cat > index.js << 'EOF'
const express = require("express");
const app = express();
const port = 3000;
app.get("/", (req, res) => {
res.json({ message: "Hello from the API service" });
});
app.listen(port, "0.0.0.0", () => {
console.log(`API service listening on port ${port}`);
});
EOF
What does this code do?
This is a minimal Express server that listens on port 3000. When someone sends a GET request to /, it responds with a JSON object containing a message.
The 0.0.0.0 binding makes the server accessible from outside the container, not just from inside it.
- Confirm both files exist by running:
ls
You should see index.js and package.json listed in the output.
- Create the Dockerfile by running this command:
cat > Dockerfile << 'EOF'
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY index.js ./
EXPOSE 3000
CMD ["npm", "start"]
EOF
What does the Dockerfile do?
Each line is an instruction that builds up your container image layer by layer. It starts from a lightweight node:20-alpine base image, copies package.json first and installs dependencies, then copies your application code.
Copying package.json before the source code is a Docker caching trick. If your code changes but dependencies stay the same, Docker skips re-installing packages on the next build.
- Confirm all three files exist by running:
ls
You should now see Dockerfile, index.js, and package.json in the output.
Missing a file?
If ls does not show all three files, re-run the cat command for the missing file. Make sure you include the EOF closing line on its own line with no leading spaces.
help me figure out why my heredoc file creation is not working
✔️ Awesome, I've got everything!
Great. All three files are in place and you are ready to build.
ⓧ I'd like to double check the full code
Here is each file for comparison.
{
"name": "terraform-docker-api",
"version": "1.0.0",
"description": "Simple API for Terraform Docker project",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "4.21.2"
}
}
const express = require("express");
const app = express();
const port = 3000;
app.get("/", (req, res) => {
res.json({ message: "Hello from the API service" });
});
app.listen(port, "0.0.0.0", () => {
console.log(`API service listening on port ${port}`);
});
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY index.js ./
EXPOSE 3000
CMD ["npm", "start"]
Build the Docker image and run the container
With your files written, you can now build a Docker image and start a container from it. This is the manual workflow that most tutorials teach.
- Build the Docker image by running this command (make sure you are still inside the ~/infra-project/app directory):
docker build -t api-service:latest .
Docker will download the node:20-alpine base image, install your dependencies, and package everything into an image. You will see each build step printed in your terminal.
What does this command do?
The -t api-service:latest flag gives your image a name and tag so you can reference it later. The . at the end tells Docker to use the current directory as the build context (where it looks for the Dockerfile and files to copy).
Seeing a permission denied error?
If you see permission denied while trying to connect to the Docker daemon socket, your user is not in the docker group yet. Run newgrp docker to activate the group membership you set up in Step 1, then try the build again.
help me fix Docker permission denied on my Ubuntu server
- Start the container by running:
docker run -d -p 3000:3000 --name my-api api-service:latest
What does this command do?
The -d flag runs the container in the background so you get your terminal back. The -p 3000:3000 maps port 3000 on your server to port 3000 inside the container. The --name my-api gives the container a human-readable name so you can reference it later.
- Confirm the container is running by running:
docker ps
You should see a row for my-api with status Up and port 3000->3000/tcp listed.
Container not showing in docker ps?
Run docker ps -a to see all containers, including stopped ones. If your container exited immediately, check the logs with docker logs my-api to see what went wrong.
help me figure out why my Docker container exited immediately
Test the container, then tear it down
Your container is running in the background. Time to prove it actually works by sending it a request.
Before you run the next command, what do you expect the API to respond with?
- Test the running container by running:
curl http://localhost:3000
You should see this JSON response:
{"message":"Hello from the API service"}
That confirms your API container is serving requests.
Seeing 'connection refused'?
Make sure the container is actually running with docker ps. If it is running but curl still fails, wait a few seconds for the Node.js process to start, then try again.
help me troubleshoot why curl cannot connect to my Docker container
- Stop the container by running:
- Remove the container by running:
docker stop my-api
docker rm my-api
Your container is gone. The API no longer responds.
Why does this matter?
Think about what you just did. You typed at least six separate commands: mkdir, three cat heredocs, docker build, and docker run. None of those commands are saved anywhere.
If you needed to deploy this on another server, you would have to remember every step. If you needed to add a database and a cache, the manual commands would multiply. This is exactly the problem Infrastructure as Code solves.
You have proven that your API works in a container. Next up, you will define this exact same container in a Terraform file so that a single command replaces all the manual steps you just typed.
Define Your Infrastructure in Terraform
In the previous step, you deployed a container by hand and saw how many separate commands it took. If you needed to reproduce that on another server, you would have to remember every command in the right order.
Now you will define that same container in a single Terraform configuration file. One file replaces a dozen commands, and you can destroy and rebuild your entire stack with a single command.
In this step, get ready to:
- Write a Terraform configuration that declares the Docker provider and a dedicated network.
- Define Docker image and container resources so Terraform manages your API.
- Deploy the full configuration with a single command and verify it works.
Create the Terraform configuration with the Docker provider
Terraform needs to know which provider plugin to use and how to connect to it. You will also create a dedicated Docker network that all your containers will share.
- Create main.tf in your infra-project directory by opening it in nano:
nano main.tf
- Paste the following code into the file:
# Define the Docker provider from kreuzwerker
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "4.5.0"
}
}
}
# Connect to Docker on this machine
provider "docker" {
host = "unix:///var/run/docker.sock"
}
# Create a dedicated network for our containers
resource "docker_network" "app_network" {
name = "app-network"
driver = "bridge"
}
- Save and exit nano by pressing Ctrl+X, then Y, then Enter.
What does this code do?
- The terraform block tells Terraform to download the kreuzwerker/docker provider at version 4.5.0. This provider gives Terraform the ability to manage Docker resources.
- The provider block connects to Docker through the local socket at unix:///var/run/docker.sock. Since Terraform is running on the same machine as Docker, no network configuration is needed.
- The docker_network resource creates a bridge network named app-network. Containers on this network can discover and talk to each other by name.
- Confirm the file was created by running:
ls main.tf
You should see main.tf listed in the output.
Don't see main.tf?
- Make sure you pressed Ctrl+X then Y then Enter to save. If you pressed N instead of Y, nano discards the file.
- Confirm you are in the ~/infra-project directory by running pwd.
Still stuck? Help me create main.tf in my infra-project directory
Add the image and container resources
With the provider and network defined, you can now tell Terraform to build your API image and run it as a container on the network.
- Open main.tf again by running:
nano main.tf
- Add the following code below the docker_network resource (after the closing }):
# Build the API image from the local Dockerfile
resource "docker_image" "api" {
name = "api-service:latest"
build {
context = "./app"
dockerfile = "Dockerfile"
}
}
# Run the API container on our network
resource "docker_container" "api" {
name = "api"
image = docker_image.api.image_id
networks_advanced {
name = docker_network.app_network.name
}
ports {
internal = 3000
external = 3000
}
restart = "unless-stopped"
}
- Save and exit nano by pressing Ctrl+X, then Y, then Enter.
What does this code do?
- The docker_image resource builds your API image from the Dockerfile in the ./app directory. Terraform handles the build step for you.
- The docker_container resource runs that image as a container named api. It references the image using docker_image.api.image_id, which Terraform resolves automatically.
- The networks_advanced block attaches the container to app-network. Later, other containers on this network will be able to reach the API by its container name.
- The ports block maps port 3000 inside the container to port 3000 on the host, just like the -p 3000:3000 flag you used manually.
- The restart = "unless-stopped" policy keeps the container running even after you disconnect your SSH session.
The next substep initializes Terraform and applies this configuration so you can see everything in action.
Initialize and apply your Terraform configuration
Terraform requires a one-time initialization to download the provider plugin you declared. After that, a single terraform apply command will create everything defined in your file.
- Download the Docker provider by running:
terraform init
You should see output ending with Terraform has been successfully initialized! confirming the provider was downloaded.
What did terraform init do?
It read your main.tf, found the kreuzwerker/docker provider requirement, downloaded the plugin into a hidden .terraform directory, and created a lock file (.terraform.lock.hcl) to pin the provider version. You only need to run terraform init again if you add or change providers.
Seeing an error during init?
- If you see a syntax error, open main.tf and check that every opening { has a matching closing }.
- If you see a network error, confirm your server has internet access by running ping -c 1 registry.terraform.io.
Help me fix a terraform init error on my Ubuntu server
Before you deploy, what do you think terraform apply will create? Think about which resources you defined in main.tf.
- Deploy your infrastructure by running:
terraform apply
What will terraform apply ask you?
Terraform will show you a plan listing the resources it intends to create, then prompt you with:
Do you want to perform these actions?
Enter a value: yes
Type yes and press Enter to confirm. Terraform will then build the image, create the network, and start the container.
You should see output ending with Apply complete! Resources: 3 added, 0 changed, 0 destroyed. confirming all three resources (network, image, container) were created.
- Verify the container is responding by running:
curl http://localhost:3000
You should see the JSON response:
{"message":"Hello from the API service"}
This is the exact same response you saw in the previous step. The difference is that every piece of infrastructure is now defined in a single file you can version-control, share, and reproduce on any machine.
Why is Infrastructure as Code better?
Compare what you just did to Step 2. In Step 2 you ran separate docker build, docker run, and docker network create commands with no record of what you typed.
Now everything lives in main.tf. You can terraform destroy to tear it all down and terraform apply to rebuild it identically. That is the core promise of Infrastructure as Code.
Curl not returning a response?
- Run docker ps to check if the api container is running.
- If the container exited, check its logs with docker logs api to see what went wrong.
- Make sure you typed yes (not y) at the terraform apply prompt. Terraform only accepts the full word.
Help me troubleshoot why my Terraform-deployed container is not responding to curl
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf and that curl http://localhost:3000 is returning the JSON response.
ⓧ I'd like to double check the full code
Your main.tf should contain these sections in this order:
- The terraform block declaring kreuzwerker/docker version 4.5.0.
- The provider "docker" block with host = "unix:///var/run/docker.sock".
- A docker_network resource named app_network with name = "app-network" and driver = "bridge".
- A docker_image resource named api with a build block pointing at ./app.
- A docker_container resource named api with networks_advanced, port 3000 mapped, and restart = "unless-stopped".
Scroll through your file in nano and confirm each section is present in order. The app/ directory files (index.js, package.json, Dockerfile) remain unchanged from the previous step.
Your infrastructure is now code. In the next step, you will add PostgreSQL and Redis containers to main.tf and prove that all three containers can communicate by name over the Docker network.
Add PostgreSQL and Redis
Your API is running inside a container, deployed from code. That is a solid start.
A single container is not a microservices architecture, though. Real services need persistent storage and caching. In this step, you will add a PostgreSQL database for persistent data and a Redis cache to reduce database load. Both containers will discover your API by name over the same Docker network, which is exactly how your future auth, inventory, and ops services will communicate.
In this step, get ready to:
- Add Terraform resources for PostgreSQL and Redis containers on your existing network.
- Update your Node.js API to write data to PostgreSQL and cache reads in Redis.
- Deploy the full stack and verify containers communicate by name.
Add PostgreSQL and Redis to your Terraform configuration
Containers on the same user-defined bridge network can resolve each other by name using Docker's embedded DNS. You will add PostgreSQL and Redis to the same app-network so your API can reach them at postgres and redis as hostnames.
- In your SSH session, open main.tf for editing.
- Find the existing docker_image "api" resource. Below it, add the image resources for Redis and PostgreSQL:
resource "docker_image" "redis" {
name = "redis:7-alpine"
keep_locally = true
}
resource "docker_image" "postgres" {
name = "postgres:16-alpine"
keep_locally = true
}
What do these resources do?
Each docker_image resource tells Terraform to pull a specific image from Docker Hub. The keep_locally = true flag means Terraform will not delete the image when you run terraform destroy. This saves bandwidth on rebuilds.
- Below the image resources, add the PostgreSQL container resource:
resource "docker_container" "postgres" {
name = "postgres"
image = docker_image.postgres.image_id
networks_advanced {
name = docker_network.app_network.name
}
env = [
"POSTGRES_USER=appuser",
"POSTGRES_PASSWORD=apppassword",
"POSTGRES_DB=appdb"
]
ports {
internal = 5432
external = 5432
}
volumes {
volume_name = "pgdata"
container_path = "/var/lib/postgresql/data"
}
restart = "unless-stopped"
}
What does this container do?
The env block passes environment variables that PostgreSQL uses to create a database and user on first boot. The volumes block mounts a named volume so your data survives container restarts.
The container name postgres becomes its DNS hostname on the Docker network. Your API will connect to it using this name instead of an IP address.
- Below the PostgreSQL container, add the Redis container resource:
resource "docker_container" "redis" {
name = "redis"
image = docker_image.redis.image_id
networks_advanced {
name = docker_network.app_network.name
}
restart = "unless-stopped"
}
Why is Redis so simple?
Redis runs in-memory and needs no credentials or volumes for this use case. It only needs to be on the same network so your API can reach it at redis:6379.
- Now find the existing docker_container "api" resource. Find this line:
restart = "unless-stopped"
}
- Add a depends_on block just above restart, so the API container section now ends like this:
restart = "unless-stopped"
depends_on = [docker_container.redis, docker_container.postgres]
}
What does depends_on do?
The depends_on meta-argument tells Terraform to start PostgreSQL and Redis before the API container. This gives the databases time to initialize before the API tries to connect.
- Save main.tf.
- Preview what Terraform will create by running:
terraform plan
You should see Terraform reporting 4 new resources to add: 2 images and 2 containers. The existing API container will be updated in-place to add the depends_on relationship.
Seeing a syntax error?
Check that every opening brace { has a matching closing brace }. Make sure the depends_on line sits inside the docker_container "api" block, not after the closing brace.
help me fix a syntax error in my Terraform main.tf file
Update the API to use PostgreSQL and Redis
Your API currently returns a static JSON message. You will update it to write request data to PostgreSQL and cache responses in Redis, proving that all three containers communicate over the Docker network.
- Open app/package.json and replace its contents with the following to add the PostgreSQL and Redis client libraries:
{
"name": "terraform-docker-api",
"version": "1.0.0",
"description": "Simple API for Terraform Docker project",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "4.21.2",
"pg": "8.22.0",
"redis": "4.7.0"
}
}
What are these new packages?
The pg package is the PostgreSQL client for Node.js. The redis package is the official Redis client. Both let your API connect to the database containers using their network hostnames.
- Save app/package.json.
- Now open app/index.js and replace the entire file contents. Start with the imports and client setup:
const express = require("express");
const redis = require("redis");
const { Pool } = require("pg");
const app = express();
const port = 3000;
// Create a Redis client pointing at the 'redis' container by name
const redisClient = redis.createClient({
url: "redis://redis:6379",
});
redisClient.on("error", (err) => {
console.log("Redis not available:", err.message);
});
// Create a PostgreSQL connection pool pointing at the 'postgres' container
const pool = new Pool({
host: "postgres",
port: 5432,
database: "appdb",
user: "appuser",
password: "apppassword",
});
What is happening here?
The Redis client connects to redis://redis:6379 where redis is the container name that Docker DNS resolves on the shared network. The Pool connects to the postgres container using the same credentials you set in the Terraform env block.
- Below the client setup, add the initialization functions:
// Initialize the database table on startup
async function initDB() {
try {
const client = await pool.connect();
await client.query(`
CREATE TABLE IF NOT EXISTS requests (
id SERIAL PRIMARY KEY,
path TEXT NOT NULL,
timestamp TIMESTAMPTZ DEFAULT NOW()
)
`);
client.release();
console.log("Database initialized");
} catch (err) {
console.log("Database not ready, retrying in 2s...");
setTimeout(initDB, 2000);
}
}
// Connect to Redis with graceful fallback
async function connectRedis() {
try {
await redisClient.connect();
console.log("Connected to Redis");
} catch (err) {
console.log("Redis not available, running without cache");
}
}
connectRedis();
initDB();
Why the retry logic?
Even with depends_on in Terraform, a container starting does not mean the database process inside it is ready to accept connections. The initDB function retries every 2 seconds until PostgreSQL finishes booting. This is a common resilience pattern for containerized applications.
- Finally, add the route handler and server listener below the initialization calls:
// Handle GET / with cache-then-database logic
app.get("/", async (req, res) => {
const cacheKey = "api:latest";
// Check Redis cache first
if (redisClient.isReady) {
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.json({ source: "cache", data: JSON.parse(cached) });
}
}
try {
// Log the request to PostgreSQL
await pool.query("INSERT INTO requests (path) VALUES ($1)", ["/"]);
const result = await pool.query("SELECT COUNT(*) as total FROM requests");
const data = {
service: "api",
message: "Hello from the API service",
totalRequests: parseInt(result.rows[0].total),
timestamp: new Date().toISOString(),
};
// Cache the response in Redis for 10 seconds
if (redisClient.isReady) {
await redisClient.set(cacheKey, JSON.stringify(data), { EX: 10 });
}
res.json({ source: "database", data });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(port, "0.0.0.0", () => {
console.log(`API service listening on port ${port}`);
});
What is the cache-then-database pattern?
On each request, the API checks Redis first. If cached data exists (within the 10-second window), it returns immediately with source: "cache". If the cache is empty, it queries PostgreSQL, stores the result in Redis, and returns with source: "database".
This pattern reduces database load in production. Frequently accessed data is served from memory instead of hitting the disk every time.
- Save app/index.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/package.json and app/index.js.
ⓧ I'd like to double check the full code
Your app/index.js should contain all three sections you added, in this order:
- Imports and client setup (express, redis, pg imports, then redisClient and pool configuration).
- The initDB and connectRedis functions, followed by calls to both.
- The app.get("/", ...) handler with cache-then-database logic, followed by app.listen.
Scroll up and confirm each section is present in order. Make sure no leftover code from the original index.js remains (the old app.get handler should be gone).
Deploy the full stack and test
Your Terraform configuration now defines three containers on one network. Your API code knows how to talk to both. Time to deploy everything and see if the containers can actually find each other.
- Deploy the full stack by running:
terraform apply
Terraform will show a plan that includes destroying and recreating the API container (because the image changed), plus creating the new PostgreSQL and Redis containers. Type yes to confirm.
- Wait about 5 seconds for PostgreSQL to finish initializing inside its container.
Before you test, think about what you expect. When you hit the API for the first time, will the response come from the cache or the database?
- Hit the API for the first time by running:
curl http://localhost:3000
You should see a JSON response with "source": "database". The cache was empty, so the API queried PostgreSQL and stored the result in Redis.
- Now hit the same endpoint again immediately:
curl http://localhost:3000
This time you should see "source": "cache". Redis served the cached response without touching PostgreSQL. All three containers are communicating over the Docker network by name.
Seeing a connection error or empty response?
If you see "error": "connection refused" or similar, PostgreSQL may still be starting. Wait 5 more seconds and try again.
If the API container is not running at all, check its logs:
docker logs api
Look for messages about missing modules (pg or redis). If you see these, confirm your app/package.json has both dependencies and run terraform apply again to rebuild the image.
help me debug why my Docker containers cannot connect to each other on the same network
Three containers are now talking to each other by name, deployed entirely from code. Next up, you will add Nginx as a reverse proxy to sit in front of your API, just like a production gateway.
Add Nginx as a Reverse Proxy
Your API now talks to both PostgreSQL and Redis over the Docker network, with containers discovering each other by name. But right now, anyone who wants to reach your API needs to know it runs on port 3000. In production, you never expose application containers directly to the outside world.
Nginx is a reverse proxy that sits in front of your services. It provides a single entry point, routes traffic to the right container, and shields your application from direct exposure. In this step, you will add Nginx to your Terraform stack so all external traffic flows through one gateway.
In this step, get ready to:
- Create an Nginx configuration file that proxies to your API by container name.
- Add Terraform resources for the Nginx image and container.
- Prove reproducibility by tearing down and rebuilding the entire stack.
Create the Nginx configuration file
Nginx needs a configuration file that tells it where to forward incoming requests. Because your containers share a Docker network, Nginx can reach your API by its container name api instead of an IP address.
- Create the nginx directory inside your project by running this command:
mkdir -p ~/infra-project/nginx
- Create and open the Nginx configuration file by running:
nano ~/infra-project/nginx/nginx.conf
- Paste the following configuration into the file:
# Configure connection handling
events {
worker_connections 1024;
}
http {
# Define the backend service using the container name
upstream api_service {
server api:3000;
}
server {
listen 80;
# Forward all requests to the API service
location / {
proxy_pass http://api_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
What does this configuration do?
- upstream api_service defines a named backend pointing to your api container on port 3000. Docker's network DNS resolves the container name to its IP automatically.
- listen 80 means Nginx listens inside the container on port 80. Terraform will map this to port 8080 on the host.
- proxy_pass forwards every request to the upstream. The headers preserve the original client IP and hostname.
- Save the file by pressing Ctrl+O then Enter, and exit nano with Ctrl+X.
- Confirm the file was created by running:
ls ~/infra-project/nginx/
You should see nginx.conf listed in the output.
Don't see nginx.conf?
- Make sure you saved inside nano (Ctrl+O confirms the write).
- Check you created the directory first with mkdir -p ~/infra-project/nginx.
Help me troubleshoot why my nginx.conf file was not created.
✔️ Awesome, I've got everything!
Great. Make sure you have saved nginx/nginx.conf before continuing.
ⓧ I'd like to double check the full code
events {
worker_connections 1024;
}
http {
upstream api_service {
server api:3000;
}
server {
listen 80;
location / {
proxy_pass http://api_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
Add Terraform resources for the Nginx container
With the configuration file ready, you need two new Terraform resources: one to pull the Nginx image, and one to run the container with the config mounted.
- Open main.tf in your editor:
nano ~/infra-project/main.tf
- Scroll to the bottom of the file (after the docker_container "api" resource) and add the following Nginx image and container resources:
resource "docker_image" "nginx" {
name = "nginx:alpine"
keep_locally = true
}
resource "docker_container" "nginx" {
name = "nginx"
image = docker_image.nginx.image_id
# Attach to the shared app network
networks_advanced {
name = docker_network.app_network.name
}
# Map container port 80 to host port 8080
ports {
internal = 80
external = 8080
}
# Mount the config file into the container
volumes {
host_path = "/home/[[USERNAME="your Linux username"]]/infra-project/nginx/nginx.conf"
container_path = "/etc/nginx/nginx.conf"
read_only = true
}
restart = "unless-stopped"
depends_on = [docker_container.api]
}
What do these resources do?
- docker_image "nginx" pulls the lightweight Alpine-based Nginx image.
- docker_container "nginx" runs Nginx on the same network as your other containers. Port 8080 on the host maps to port 80 inside the container.
- The volumes block mounts your nginx.conf from the host into the container as read-only. This means you can update the config without rebuilding the image.
- depends_on ensures Terraform starts the API container before Nginx, so Nginx has a backend ready to proxy to.
Why a reverse proxy instead of direct access?
Exposing application containers directly means every service needs its own public port. A reverse proxy consolidates all traffic through one entry point.
This is the same gateway pattern used in production microservices. When you add more services later (like an inventory service), Nginx can route to all of them by URL path without exposing additional ports.
- Save main.tf with Ctrl+O then Enter, and exit with Ctrl+X.
Not sure where to paste?
The new resources go after the closing } of the docker_container "api" block. In nano, use Ctrl+End to jump to the bottom of the file.
Help me figure out where to add the Nginx resources in my main.tf file.
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.tf before continuing.
ⓧ I'd like to double check the full code
Your main.tf should now contain these resources in order. Scroll through your file and confirm each one is present:
- terraform block with the kreuzwerker/docker provider (version 4.5.0).
- provider "docker" block pointing to the local socket.
- docker_network "app_network" (bridge driver).
- docker_image "api" with the build block.
- docker_image "redis" (redis:7-alpine).
- docker_image "postgres" (postgres:16-alpine).
- docker_image "nginx" (nginx:alpine).
- docker_container "postgres" with env, ports, and volumes.
- docker_container "redis" on the app network.
- docker_container "api" with port 3000 and depends_on.
- docker_container "nginx" with port 8080, volumes mount, and depends_on api.
Deploy and prove reproducibility
Time to deploy the updated stack with Nginx added, then tear it all down and rebuild it to prove that your infrastructure truly is code.
- Navigate to your project directory by running:
cd ~/infra-project
- Apply the updated Terraform configuration by running:
terraform apply
What to expect from the prompt
Terraform shows a plan listing the new resources it will create (the Nginx image and container). It asks for confirmation. Type yes and press Enter to proceed.
Terraform creates the Nginx container and attaches it to the network. You should see a summary ending with resources added.
Before you test, think about this: if you curl port 8080, do you expect to see the same JSON response as port 3000, or something different?
- Test that traffic routes through Nginx by running:
curl http://localhost:8080
You see the same JSON response you got from port 3000, but this time it arrived through Nginx on port 8080. Port 8080 is now the production-style entry point for your stack.
Getting a connection refused on port 8080?
- Run docker ps and check that a container named nginx is running. If it exited, run docker logs nginx to see the error.
- A common cause is a typo in nginx.conf. Check that server api:3000 matches your API container name exactly.
- Verify the host_path in main.tf points to the actual absolute path of your nginx.conf file. Run realpath ~/infra-project/nginx/nginx.conf to confirm.
Help me debug why curl to port 8080 is failing after terraform apply.
Now for the real proof. If your infrastructure is truly defined as code, you should be able to destroy everything and recreate it identically.
- Tear down the entire stack by running:
terraform destroy
What to expect from the destroy prompt
Terraform shows every resource it will remove (containers, images, network). Type yes and press Enter to confirm the teardown.
- Verify everything is gone by running:
docker ps
You should see no running containers. The entire stack is gone.
Now think: can you get the full four-container stack back with a single command?
- Rebuild the identical stack by running:
terraform apply
- Type yes when prompted to confirm.
- Once it completes, test the rebuilt stack by running:
curl http://localhost:8080
The same JSON response appears. You destroyed everything and rebuilt it identically with one command. That is Infrastructure as Code.
Why does this matter?
If your server dies, you move to a new team, or you need to spin up an identical environment for testing, you run terraform apply and get the same stack every time. No guessing, no forgotten steps, no snowflake servers.
Secret mission
Add a Second Service and Route by Path
Your real goal is multiple services behind one gateway. This mission adds a second API (simulating an "inventory" service) and configures Nginx to route /api to your original service and /inventory to the new one, just like a real microservices gateway.
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 on your local Linux server, so there are no ongoing cloud costs. The containers do consume memory and CPU while running.
Resources you used:
- Docker containers (postgres, redis, api, inventory, nginx) running on your server.
- Docker images (api-service, inventory-service, redis, postgres, nginx) stored locally.
- Docker volume (pgdata) holding PostgreSQL data.
- Docker network (app-network) connecting the containers.
- Project directory ~/infra-project with all Terraform and application files.
- Installed tools: Docker Engine, Terraform, Git.
Keep everything running
No action needed. Choose this if you are still actively building or plan to add more services to your stack. Your containers will keep running thanks to the unless-stopped restart policy, even after you disconnect your SSH session.
- Docker Engine, Terraform, and Git remain installed on your server.
- All five containers continue running and consuming memory.
- Your project files stay in ~/infra-project.
Pause - I'll come back to this later
Shut down all running containers to free up memory, but keep your project files and tools intact. You can bring everything back with a single terraform apply when you return.
- In your SSH session, navigate to your project directory by running:
cd ~/infra-project
- Tear down all containers and the network by running:
terraform destroy
- Type yes when prompted to confirm.
- Your .tf files, application code, and Terraform state remain on disk.
- When you return, run terraform apply from ~/infra-project to rebuild the entire stack.
Delete - I don't want to use this again
Remove all project resources, containers, images, volumes, and optionally the installed tools. This gives you a clean server.
- Navigate to your project directory and destroy all Terraform-managed resources by running:
cd ~/infra-project
terraform destroy
- Type yes when prompted to confirm.
- Remove leftover Docker images and the pgdata volume by running:
docker image prune -a -f
docker volume rm pgdata
- Delete the entire project directory by running:
rm -rf ~/infra-project
- To uninstall Terraform, run:
sudo apt remove terraform
- To uninstall Docker Engine and its components, run:
sudo apt-get purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo rm -rf /var/lib/docker
sudo rm -rf /var/lib/containerd
- To uninstall Git, run:
sudo apt remove git
Nice Work!
Nice Work!
Nice work! You just built a complete multi-container application stack on a fresh Ubuntu Server, entirely managed by Terraform configuration files.
You've learned how to:
- Write Terraform configuration that declaratively deploys a multi-container stack (API + PostgreSQL + Redis + Nginx) and tear it down or rebuild it with a single command.
- Create a Docker network where containers discover each other by name, the exact communication pattern used in microservices architectures.
- Set up Nginx as a reverse proxy gateway in front of your services, routing external traffic to the correct container.
- Secret Mission: Built a multi-service gateway with path-based routing, the exact architecture for routing to multiple backend services from a single entry point.
Ready to quiz yourself?