DevOps Automation with Terraform and GitHub Actions
Streamlining infrastructure deployment and CI/CD pipelines using Infrastructure as Code and automated workflows
DevOps Automation with Terraform and GitHub Actions ⚙️
The days of manually provisioning servers and deploying applications are long gone. In today’s cloud-native world, Infrastructure as Code (IaC) and automated CI/CD pipelines are not just nice-to-have features—they’re essential for maintaining velocity and reliability.
The Problem with Manual Operations
Before embracing automation, our deployment process looked something like this:
- Manual server provisioning - SSH into AWS console, click around, hope for the best
- Configuration drift - Servers slowly diverged from their intended state
- Deployment anxiety - Every release was a potential disaster
- Inconsistent environments - Dev, staging, and production were never quite the same
- Rollback nightmares - When things went wrong, recovery was painful
Sound familiar? Let me show you how we solved these problems.
Infrastructure as Code with Terraform
Terraform has become the backbone of our infrastructure management. Here’s how we structure our projects:
Project Structure
infrastructure/
├── environments/
│ ├── dev/
│ ├── staging/
│ └── prod/
├── modules/
│ ├── vpc/
│ ├── eks/
│ ├── rds/
│ └── redis/
├── shared/
│ ├── providers.tf
│ └── variables.tf
└── scripts/
├── plan.sh
└── apply.sh
VPC Module
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.environment}-igw"
Environment = var.environment
}
}
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.environment}-public-${count.index + 1}"
Environment = var.environment
Type = "public"
}
}
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.environment}-private-${count.index + 1}"
Environment = var.environment
Type = "private"
}
}
EKS Cluster Module
# modules/eks/main.tf
resource "aws_eks_cluster" "main" {
name = "${var.environment}-cluster"
role_arn = aws_iam_role.cluster.arn
version = var.kubernetes_version
vpc_config {
subnet_ids = var.subnet_ids
endpoint_private_access = true
endpoint_public_access = true
public_access_cidrs = var.allowed_cidr_blocks
}
encryption_config {
provider {
key_arn = aws_kms_key.eks.arn
}
resources = ["secrets"]
}
depends_on = [
aws_iam_role_policy_attachment.cluster_AmazonEKSClusterPolicy,
aws_cloudwatch_log_group.cluster,
]
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "${var.environment}-nodes"
node_role_arn = aws_iam_role.nodes.arn
subnet_ids = var.subnet_ids
scaling_config {
desired_size = var.node_desired_size
max_size = var.node_max_size
min_size = var.node_min_size
}
update_config {
max_unavailable_percentage = 25
}
depends_on = [
aws_iam_role_policy_attachment.nodes_AmazonEKSWorkerNodePolicy,
aws_iam_role_policy_attachment.nodes_AmazonEKS_CNI_Policy,
aws_iam_role_policy_attachment.nodes_AmazonEC2ContainerRegistryReadOnly,
]
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
GitHub Actions for CI/CD
Our CI/CD pipeline is built on GitHub Actions, providing seamless integration with our code repository.
Workflow Structure
# .github/workflows/deploy.yml
name: Deploy Infrastructure
on:
push:
branches: [main]
paths: ['infrastructure/**']
pull_request:
branches: [main]
paths: ['infrastructure/**']
env:
AWS_REGION: us-west-2
TF_VERSION: '1.6.0'
jobs:
terraform-plan:
name: 'Terraform Plan'
runs-on: ubuntu-latest
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'development' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
working-directory: infrastructure/environments/prod
run: terraform init
- name: Terraform Validate
working-directory: infrastructure/environments/prod
run: terraform validate
- name: Terraform Plan
working-directory: infrastructure/environments/prod
run: terraform plan -out=tfplan
- name: Upload Plan
uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: infrastructure/environments/prod/tfplan
terraform-apply:
name: 'Terraform Apply'
runs-on: ubuntu-latest
needs: terraform-plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Download Plan
uses: actions/download-artifact@v4
with:
name: terraform-plan
- name: Terraform Apply
working-directory: infrastructure/environments/prod
run: terraform apply -auto-approve tfplan
Application Deployment Workflow
# .github/workflows/app-deploy.yml
name: Deploy Application
on:
push:
branches: [main]
paths: ['src/**', 'Dockerfile', 'k8s/**']
pull_request:
branches: [main]
paths: ['src/**', 'Dockerfile', 'k8s/**']
env:
AWS_REGION: us-west-2
ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
ECR_REPOSITORY: my-app
jobs:
test:
name: 'Run Tests'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linting
run: npm run lint
build-and-push:
name: 'Build and Push Docker Image'
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
deploy:
name: 'Deploy to Kubernetes'
runs-on: ubuntu-latest
needs: build-and-push
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Update kubeconfig
run: aws eks update-kubeconfig --region ${{ env.AWS_REGION }} --name production-cluster
- name: Deploy to Kubernetes
run: |
# Update image tag in deployment
sed -i "s|IMAGE_TAG|${{ github.sha }}|g" k8s/deployment.yaml
# Apply Kubernetes manifests
kubectl apply -f k8s/
# Wait for rollout to complete
kubectl rollout status deployment/my-app -n production
# Verify deployment
kubectl get pods -n production -l app=my-app
Kubernetes Manifests
Our application deployments are defined as Kubernetes manifests:
Deployment
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
namespace: production
labels:
app: my-app
version: v1
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
version: v1
spec:
containers:
- name: my-app
image: IMAGE_TAG
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
Service
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: my-app-service
namespace: production
spec:
selector:
app: my-app
ports:
- port: 80
targetPort: 3000
type: ClusterIP
Ingress
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app-ingress
namespace: production
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- myapp.example.com
secretName: myapp-tls
rules:
- host: myapp.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app-service
port:
number: 80
Monitoring and Alerting
Prometheus Configuration
# monitoring/prometheus-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: monitoring
data:
prometheus.yml: |
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "rules/*.yml"
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093
scrape_configs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
Grafana Dashboard
{
"dashboard": {
"title": "Application Metrics",
"panels": [
{
"title": "Request Rate",
"type": "graph",
"targets": [
{
"expr": "rate(http_requests_total[5m])",
"legendFormat": "{{method}} {{endpoint}}"
}
]
},
{
"title": "Response Time",
"type": "graph",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))",
"legendFormat": "95th percentile"
}
]
}
]
}
}
Security Best Practices
RBAC Configuration
# k8s/rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: app-role
rules:
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: app-rolebinding
namespace: production
subjects:
- kind: ServiceAccount
name: my-app
namespace: production
roleRef:
kind: Role
name: app-role
apiGroup: rbac.authorization.k8s.io
Network Policies
# k8s/network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: my-app-netpol
namespace: production
spec:
podSelector:
matchLabels:
app: my-app
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- protocol: TCP
port: 3000
egress:
- to:
- namespaceSelector:
matchLabels:
name: database
ports:
- protocol: TCP
port: 5432
The Results
After implementing this automation pipeline:
- 90% reduction in deployment time
- Zero-downtime deployments with rolling updates
- Consistent environments across dev, staging, and production
- Automated rollbacks when issues are detected
- Infrastructure drift eliminated with Terraform state management
- Faster incident response with comprehensive monitoring
Key Takeaways
- Start with Infrastructure as Code - Terraform makes infrastructure changes safe and repeatable
- Automate everything - Manual processes are error-prone and slow
- Monitor from day one - You can’t fix what you can’t see
- Security is not optional - Implement RBAC, network policies, and secrets management
- Document everything - Your future self will thank you
The journey to full automation isn’t easy, but the benefits are enormous. Your team will be more productive, your deployments will be more reliable, and you’ll sleep better at night knowing your infrastructure is properly managed.
Want to see more automation examples? Check out my GitHub for complete Terraform modules and GitHub Actions workflows!