Deploy Docker on AWS with GitHub Actions

๐Ÿณ Docker โ˜๏ธ Amazon Web Services โš™๏ธ GitHub Actions

Configuration Files

3 files
Production-ready configuration files with detailed comments and best practices. Each file works together as a complete deployment solution.
Multi-stage Docker build for optimized image size and security
dockerfile
# Multi-stage build for optimized image size
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files first for better layer caching
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production && \
    npm cache clean --force

# Copy application code
COPY . .

# Build the application
RUN npm run build

# Production stage - minimal image
FROM node:20-alpine

WORKDIR /app

# Add non-root user for security
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# Copy built application from builder
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs package*.json ./

# Switch to non-root user
USER nodejs

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"

# Start application
CMD ["node", "dist/index.js"]

Pro Tips

  • ๐ŸŽฏ Multi-stage builds reduce final image size by 60-80%
  • ๐Ÿ”’ Running as non-root user improves security posture
  • ๐Ÿ“ฆ Copy package.json first to leverage Docker layer caching
  • ๐Ÿ’ก Use Alpine Linux base image (5MB vs 900MB for full Node)
  • โšก Health checks enable ECS to automatically restart failed containers
  • ๐Ÿงน Clean npm cache to reduce image size
  • ๐Ÿ“Š Final image size: ~180MB vs 1.2GB without optimization
ECS Fargate task definition with resource limits and logging
json
{
  "family": "my-app-task",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskRole",
  "containerDefinitions": [
    {
      "name": "my-app-container",
      "image": "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/my-app:latest",
      "portMappings": [
        {
          "containerPort": 3000,
          "protocol": "tcp"
        }
      ],
      "essential": true,
      "environment": [
        {
          "name": "NODE_ENV",
          "value": "production"
        },
        {
          "name": "PORT",
          "value": "3000"
        }
      ],
      "secrets": [
        {
          "name": "DATABASE_URL",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:prod/database-url"
        },
        {
          "name": "API_KEY",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:prod/api-key"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/my-app",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs",
          "awslogs-create-group": "true"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 60
      }
    }
  ]
}

Pro Tips

  • ๐Ÿ’ฐ 256 CPU + 512 MB memory costs ~$15/month for 1 task running 24/7
  • ๐Ÿ” Use AWS Secrets Manager for sensitive data (secrets field)
  • ๐Ÿ“Š CloudWatch Logs automatically created with awslogs-create-group
  • โšก Health checks prevent traffic to unhealthy containers
  • ๐Ÿ’ก executionRole pulls images, taskRole is for app permissions
  • ๐ŸŽฏ Start with 256/512, scale up based on CloudWatch metrics
  • โš ๏ธ Replace YOUR_ACCOUNT_ID with your actual AWS account ID
Infrastructure as Code for complete ECS setup
terraform
# Terraform configuration for ECS Fargate deployment
terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

# Variables
variable "aws_region" {
  default = "us-east-1"
}

variable "app_name" {
  default = "my-app"
}

variable "app_port" {
  default = 3000
}

# ECR Repository
resource "aws_ecr_repository" "app" {
  name                 = var.app_name
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }

  encryption_configuration {
    encryption_type = "AES256"
  }
}

# ECS Cluster
resource "aws_ecs_cluster" "main" {
  name = "${var.app_name}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/${var.app_name}"
  retention_in_days = 7
}

# IAM Role for ECS Task Execution
resource "aws_iam_role" "ecs_execution_role" {
  name = "${var.app_name}-ecs-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ecs-tasks.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_execution_role_policy" {
  role       = aws_iam_role.ecs_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# IAM Role for ECS Task
resource "aws_iam_role" "ecs_task_role" {
  name = "${var.app_name}-ecs-task-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ecs-tasks.amazonaws.com"
      }
    }]
  })
}

# Security Group for ECS Tasks
resource "aws_security_group" "ecs_tasks" {
  name        = "${var.app_name}-ecs-tasks-sg"
  description = "Allow inbound traffic to ECS tasks"
  vpc_id      = aws_default_vpc.default.id

  ingress {
    from_port   = var.app_port
    to_port     = var.app_port
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# Use default VPC
resource "aws_default_vpc" "default" {}

# Get default subnets
data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [aws_default_vpc.default.id]
  }
}

# ECS Task Definition
resource "aws_ecs_task_definition" "app" {
  family                   = "${var.app_name}-task"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_execution_role.arn
  task_role_arn            = aws_iam_role.ecs_task_role.arn

  container_definitions = jsonencode([{
    name  = "${var.app_name}-container"
    image = "${aws_ecr_repository.app.repository_url}:latest"

    portMappings = [{
      containerPort = var.app_port
      protocol      = "tcp"
    }]

    environment = [
      {
        name  = "NODE_ENV"
        value = "production"
      },
      {
        name  = "PORT"
        value = tostring(var.app_port)
      }
    ]

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.app.name
        "awslogs-region"        = var.aws_region
        "awslogs-stream-prefix" = "ecs"
      }
    }

    healthCheck = {
      command     = ["CMD-SHELL", "curl -f http://localhost:${var.app_port}/health || exit 1"]
      interval    = 30
      timeout     = 5
      retries     = 3
      startPeriod = 60
    }
  }])
}

# ECS Service
resource "aws_ecs_service" "app" {
  name            = "${var.app_name}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = data.aws_subnets.default.ids
    security_groups  = [aws_security_group.ecs_tasks.id]
    assign_public_ip = true
  }
}

# Outputs
output "ecr_repository_url" {
  value = aws_ecr_repository.app.repository_url
}

output "ecs_cluster_name" {
  value = aws_ecs_cluster.main.name
}

output "ecs_service_name" {
  value = aws_ecs_service.app.name
}

Pro Tips

  • ๐Ÿš€ Run "terraform init" then "terraform apply" to create all infrastructure
  • ๐Ÿ’ฐ This setup costs ~$15/month (1 Fargate task 24/7)
  • ๐Ÿ“Š Container Insights enabled for detailed metrics ($0.30/task/month)
  • ๐Ÿ”’ ECR image scanning catches vulnerabilities automatically
  • ๐Ÿ’ก CloudWatch logs retained for 7 days (adjust retention_in_days)
  • โšก Uses default VPC - create custom VPC for production
  • ๐ŸŽฏ Outputs show ECR URL and service names for CI/CD
  • โš ๏ธ Remember to run "terraform destroy" when testing to avoid charges

Prerequisites

  • AWS account with billing enabled
  • AWS CLI installed and configured
  • Docker installed locally for testing
  • GitHub repository with admin access
  • Basic knowledge of Docker and AWS ECS
  • VPC with public subnets configured

Deployment Steps

  • Install AWS CLI: https://aws.amazon.com/cli/
  • Create ECR repository: aws ecr create-repository --repository-name my-app
  • Create ECS cluster: aws ecs create-cluster --cluster-name my-app-cluster
  • Create CloudWatch log group: aws logs create-log-group --log-group-name /ecs/my-app
  • Create IAM execution role with AmazonECSTaskExecutionRolePolicy
  • Create IAM task role with necessary permissions
  • Update task-definition.json with your AWS account ID and ARNs
  • Register task definition: aws ecs register-task-definition --cli-input-json file://task-definition.json
  • Create Application Load Balancer (ALB) in AWS Console
  • Create target group for port 3000
  • Create ECS service with ALB: aws ecs create-service --cluster my-app-cluster --service-name my-app-service --task-definition my-app-task --desired-count 1 --launch-type FARGATE --network-configuration "awsvpcConfiguration={subnets=[subnet-xxx],securityGroups=[sg-xxx],assignPublicIp=ENABLED}" --load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:...,containerName=my-app-container,containerPort=3000"
  • Create IAM user for GitHub Actions with ECR and ECS permissions
  • Add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to GitHub Secrets
  • Create .github/workflows/deploy.yml
  • Commit and push to main branch
  • Monitor deployment in GitHub Actions and ECS Console

Additional Notes

  • ๐Ÿš€ ECS Fargate - serverless container orchestration
  • ๐Ÿ“ˆ Auto-scaling based on CPU/memory metrics
  • ๐Ÿ”’ Private container registry with ECR
  • โš–๏ธ Application Load Balancer for traffic distribution
  • ๐Ÿ’ฐ Cost: ~$15-30/month for 1 task (256 CPU, 512 MB)
  • ๐Ÿ“Š CloudWatch Logs for centralized logging
  • ๐ŸŽฏ Blue/green deployments supported
  • โš ๏ธ Requires VPC, subnets, and security groups setup
  • ๐Ÿ’ก Consider AWS Copilot CLI for easier management
  • ๐Ÿ”ง Use Fargate Spot for 70% cost savings (non-critical workloads)