Terraform 3-Tier Application with Full Observability

React + Express + PostgreSQL + Prometheus + Grafana + Loki + Promtail

Complete Documentation with All Source Code & Configuration Files

Project Overview

Complete 3-tier web application (Task Manager) deployed using Terraform and Docker with full monitoring & logging stack.

React Frontend
Express Backend
PostgreSQL
Observability

Lab Environment Setup

Recommended VM Specs
  • OS: Ubuntu 22.04
  • RAM: 4 GB
  • CPU: 2 cores
  • Disk: 40 GB
Install Docker
Commands
sudo apt update
sudo apt install docker.io -y
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker $USER
docker run hello-world
Install Terraform
Commands
sudo apt install wget unzip -y
wget https://releases.hashicorp.com/terraform/1.6.6/terraform_1.6.6_linux_amd64.zip
unzip terraform_1.6.6_linux_amd64.zip
sudo mv terraform /usr/local/bin/
terraform -version

Application Source Code

Backend - app/backend/

server.js
const express = require("express")
const cors = require("cors")
const { Pool } = require("pg")
const client = require("prom-client")

const app = express()
app.use(cors())
app.use(express.json())

/* PROMETHEUS METRICS */
const collectDefaultMetrics = client.collectDefaultMetrics
collectDefaultMetrics()
app.get("/metrics", async (req,res)=>{
  res.set("Content-Type", client.register.contentType)
  res.end(await client.register.metrics())
})

/* DATABASE CONNECTION */
const pool = new Pool({
  host: process.env.DB_HOST,
  user: "postgres",
  password: process.env.DB_PASSWORD,
  database: "appdb",
  port: 5432
})

async function waitForDB() {
  while (true) {
    try {
      await pool.query("SELECT 1")
      console.log("PostgreSQL connected")
      break
    } catch (err) {
      console.log("Waiting for DB...")
      await new Promise(r => setTimeout(r, 2000))
    }
  }
}

async function initDB() {
  await waitForDB()
  await pool.query(`CREATE TABLE IF NOT EXISTS tasks (id SERIAL PRIMARY KEY, title TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`)
  console.log("Table ready")
}
initDB()

/* ROUTES */
app.post("/tasks", async (req, res) => {
  const { title } = req.body
  const result = await pool.query("INSERT INTO tasks(title) VALUES($1) RETURNING *", [title])
  res.json(result.rows[0])
})

app.get("/tasks", async (req, res) => {
  const result = await pool.query("SELECT * FROM tasks ORDER BY id DESC")
  res.json(result.rows)
})

app.delete("/tasks/:id", async (req, res) => {
  await pool.query("DELETE FROM tasks WHERE id=$1", [req.params.id])
  res.json({ message: "Deleted" })
})

app.listen(5000, () => console.log("Backend running on port 5000"))
package.json (Backend)
{
  "name": "task-backend",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.1",
    "cors": "^2.8.5",
    "prom-client": "^15.1.0"
  }
}
Dockerfile (Backend)
FROM node:18
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 5000
CMD ["node", "server.js"]

Frontend - app/frontend/

package.json (Frontend)
{
  "name": "task-frontend",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build"
  },
  "proxy": "http://backend:5000"
}
Dockerfile (Frontend)
FROM node:20
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
RUN npm install -g serve
CMD ["serve","-s","build","-l","3000"]
src/App.js
import { useEffect, useState } from "react"

function App() {
  const API = "http://192.168.29.88:5000"
  const [tasks,setTasks] = useState([])
  const [title,setTitle] = useState("")

  async function loadTasks() {
    const res = await fetch(API + "/tasks")
    const data = await res.json()
    setTasks(data)
  }

  async function addTask() {
    await fetch(API + "/tasks", {
      method:"POST",
      headers:{"Content-Type":"application/json"},
      body:JSON.stringify({title})
    })
    setTitle("")
    loadTasks()
  }

  async function deleteTask(id){
    await fetch(API + "/tasks/" + id, { method:"DELETE" })
    loadTasks()
  }

  useEffect(()=>{ loadTasks() },[])

  return (
    <div style={{padding:"40px"}}>
      <h1>Task Manager</h1>
      <input value={title} onChange={e=>setTitle(e.target.value)} placeholder="New task" />
      <button onClick={addTask}>Add</button>
      <ul>
        {tasks.map(t=>(
          <li key={t.id}>
            {t.title}
            <button onClick={()=>deleteTask(t.id)}>delete</button>
          </li>
        ))}
      </ul>
    </div>
  )
}
export default App

Terraform Root Files

provider.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
    grafana = {
      source  = "grafana/grafana"
      version = "~> 2.0"
    }
  }
}
variables.tf
variable "network_name" { default = "app_network" }
variable "postgres_password" { default = "postgres" }
variable "frontend_port" { default = 3000 }
variable "backend_port" { default = 5000 }
main.tf
module "network" { source = "./modules/network" network_name = "app_network" }
module "postgres" { source = "./modules/postgres" network = module.network.network_name password = "postgres" }
module "backend" { source = "./modules/backend" network = module.network.network_name db_host = "postgres_db" db_password = "postgres" }
module "frontend" { source = "./modules/frontend" network = module.network.network_name }
module "observability" { source = "./modules/observability" network = module.network.network_name }
backend.tf (Remote State with MinIO)
terraform {
  backend "s3" {
    bucket = "terraform-state"
    key    = "3tier/terraform.tfstate"
    region = "us-east-1"
    access_key = "admin"
    secret_key = "password"
    endpoints = { s3 = "http://192.168.29.88:9000" }
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    skip_requesting_account_id  = true
    skip_region_validation      = true
    force_path_style = true
  }
}

Terraform Modules

modules/network/main.tf
resource "docker_network" "app_network" {
  name = var.network_name
}
output "network_name" { value = docker_network.app_network.name }
modules/postgres/main.tf
resource "docker_container" "postgres" {
  name  = "postgres_db"
  image = "postgres:15"
  env = ["POSTGRES_PASSWORD=${var.password}", "POSTGRES_DB=appdb"]
  networks_advanced { name = var.network }
  ports { internal = 5432; external = 5432 }
}
modules/backend/main.tf
resource "docker_image" "backend" {
  name = "task-backend"
  build { context = "${path.root}/../app/backend" }
}
resource "docker_container" "backend" {
  name  = "backend"
  image = docker_image.backend.image_id
  env   = ["DB_HOST=${var.db_host}", "DB_PASSWORD=${var.db_password}"]
  networks_advanced { name = var.network }
  ports { internal = 5000; external = 5000 }
}
modules/frontend/main.tf
resource "docker_container" "frontend" {
  name  = "frontend"
  image = docker_image.frontend.image_id
  networks_advanced { name = var.network }
  ports { internal = 3000; external = 3000 }
}

Observability Stack - modules/observability/main.tf

resource "docker_container" "prometheus" { ... }
resource "docker_container" "grafana" { ... }
resource "docker_container" "loki" { ... }
resource "docker_container" "promtail" { ... }
resource "docker_container" "node_exporter" { ... }
resource "docker_container" "cadvisor" { ... }
resource "docker_container" "postgres_exporter" { ... }

Full file includes volumes for prometheus.yml, loki-config.yml, promtail-config.yml, and all exporters.

Grafana Configuration & Dashboards

grafana_datasource.tf
provider "grafana" {
  url  = "http://192.168.29.88:3001"
  auth = "admin:admin"
}

resource "grafana_data_source" "prometheus" {
  name = "Prometheus"
  type = "prometheus"
  url  = "http://prometheus:9090"
}
docker-dashboard.json
{
  "title": "Docker Containers",
  "panels": [{ "type": "timeseries", "title": "Container CPU Usage", "targets": [{ "expr": "rate(container_cpu_usage_seconds_total[1m])" }] }]
}
node-dashboard.json & postgres-dashboard.json
{
  "title": "Node Exporter Overview",
  "panels": [{ "expr": "rate(node_cpu_seconds_total{mode!='idle'}[1m])" }]
}

Deployment Steps

cd /home/devops/terraform-3tier/terraform
terraform init
terraform plan
terraform apply

Destroy: terraform destroy

Access URLs

Frontend App
Open
Grafana
admin/admin
Open
Prometheus
Open

Final Project Structure

terraform-3tier
├── app
│   ├── backend
│   │   ├── Dockerfile
│   │   ├── package.json
│   │   └── server.js
│   └── frontend
│       ├── Dockerfile
│       ├── package.json
│       ├── public/index.html
│       └── src
│           ├── App.js
│           └── index.js
├── monitoring
│   ├── prometheus.yml
│   ├── loki-config.yml
│   ├── promtail-config.yml
│   ├── grafana-data
│   └── loki-data
└── terraform
    ├── provider.tf
    ├── variables.tf
    ├── main.tf
    ├── backend.tf
    ├── grafana_datasource.tf
    ├── grafana_dashboards.tf
    ├── grafana_wait.tf
    ├── outputs.tf
    └── modules/
        ├── network/main.tf
        ├── postgres/main.tf
        ├── backend/main.tf
        ├── frontend/main.tf
        └── observability/main.tf

Summary & Interview Level Knowledge

This project demonstrates Infrastructure as Code, Containerization, Modular Terraform, Remote State, and Complete Observability (Metrics + Logs).

Remote State: MinIO simulates S3 for team collaboration and state locking.