0x55aa
โ† Back to Blog

GitHub Actions + AWS Deployment: Stop SSH-ing Into Production Like It's 2012 ๐Ÿš€โ˜๏ธ

โ€ข14 min read

GitHub Actions + AWS Deployment: Stop SSH-ing Into Production Like It's 2012 ๐Ÿš€โ˜๏ธ

Real talk: The first time I deployed to AWS, I literally SSH'd into an EC2 instance, ran git pull, restarted the server, and crossed my fingers. It worked! I felt like a genius. Then my boss asked, "What if the server crashes during deployment?" Me: "...I'll fix it?" Narrator: I did NOT fix it. ๐Ÿ˜…

Three production outages later, I discovered GitHub Actions + proper AWS deployment. Now I push to main, grab coffee, and watch automation do the work. Zero downtime. Full rollback capability. My stress level dropped by 90%!

Welcome to modern AWS deployment - where SSH is for emergencies, not daily deploys!

What Even Is GitHub Actions + AWS? ๐Ÿค”

GitHub Actions = CI/CD built into GitHub - Run workflows when code changes

GitHub Actions + AWS = Automated deployment heaven:

You push code โ†’ Tests run โ†’ Build happens โ†’ AWS deploys โ†’ Coffee tastes better โ˜•

Manual deployment (the old way):

ssh ec2-user@production-server
cd /var/www/app
git pull origin main  # Hope no merge conflicts!
npm install          # Pray dependencies work!
pm2 restart app      # Cross fingers!
# 5 minutes of anxiety

Automated deployment (GitHub Actions):

# .github/workflows/deploy.yml
- Push to main
- Tests run automatically
- Build happens automatically
- Deploy to AWS automatically
- Rollback if anything fails
# 0 minutes of anxiety! ๐ŸŽ‰

Translation: Stop manually deploying like it's 2012. Let robots do the boring, error-prone stuff!

The SSH Deployment Disaster ๐Ÿ’€

In production, I've deployed an e-commerce API to AWS. For the first 3 months, I deployed manually. Here's what went wrong:

The timeline of pain:

Week 1: Deployed by SSH. Forgot to restart the service. Users saw old version for 2 hours. ๐Ÿ˜ฌ

Week 3: Ran npm install in production. A dependency failed. Site down for 15 minutes while I frantically rolled back. ๐Ÿ’€

Week 5: Deployed on Friday at 5 PM (classic mistake). Broken migration script. Database locked. Spent my evening unfucking the database. ๐Ÿคฆโ€โ™‚๏ธ

Week 8: Realized I had been deploying to the STAGING server for 2 days. Production was 6 commits behind. ๐Ÿ˜ฑ

Boss: "We need a better deployment process."

Me: discovers GitHub Actions "I got this!" ๐Ÿš€

My GitHub Actions + AWS Deployment Strategy ๐ŸŽฏ

After 7+ years of AWS experience, here's the production-tested approach I use:

Architecture #1: Lambda Deployment (Serverless)

What I use it for: APIs, webhooks, background jobs

The setup:

# .github/workflows/deploy-lambda.yml
name: Deploy to AWS Lambda

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Deploy to Lambda
        run: |
          zip -r function.zip dist/ node_modules/
          aws lambda update-function-code \
            --function-name my-api \
            --zip-file fileb://function.zip

      - name: Notify Slack
        if: always()
        run: |
          curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
            -d '{"text":"Deploy completed!"}'

Why this works:

  • โœ… Tests run before deploy (catch bugs early!)
  • โœ… Automatic rollback if tests fail
  • โœ… Zero downtime (Lambda updates atomically)
  • โœ… Slack notification (know what's deployed)
  • โœ… No SSH, no manual steps, no stress!

Cost: FREE for public repos! (2,000 minutes/month free for private repos)

Architecture #2: S3 + CloudFront (Static Sites)

What I use it for: React/Vue frontends, documentation sites

# .github/workflows/deploy-frontend.yml
name: Deploy Frontend to S3

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install and build
        run: |
          npm ci
          npm run build

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Deploy to S3
        run: |
          aws s3 sync dist/ s3://my-site-bucket --delete

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DIST_ID }} \
            --paths "/*"

      - name: Verify deployment
        run: |
          curl -f https://mysite.com || exit 1

A serverless pattern that saved us: Deploy to S3, invalidate CloudFront, verify with curl. If verification fails, the workflow fails! ๐ŸŽฏ

Cost breakdown:

  • GitHub Actions: FREE (2,000 minutes)
  • S3 storage: $0.023/GB (pennies!)
  • CloudFront: $0.085/GB data transfer
  • Total for my blog: ~$3/month! ๐Ÿ’ฐ

Architecture #3: ECS/Fargate (Containerized Apps)

What I use it for: Long-running services, WebSocket servers, anything that needs Docker

# .github/workflows/deploy-ecs.yml
name: Deploy to AWS ECS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Login to ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build and push Docker image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: my-app
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

      - name: Update ECS task definition
        run: |
          aws ecs register-task-definition \
            --cli-input-json file://task-definition.json

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster production \
            --service my-app-service \
            --task-definition my-app:latest \
            --force-new-deployment

      - name: Wait for deployment
        run: |
          aws ecs wait services-stable \
            --cluster production \
            --services my-app-service

Why this is powerful:

  • โœ… Docker ensures consistency (works on my machine = works in prod!)
  • โœ… ECS handles rolling updates (zero downtime!)
  • โœ… Automatic rollback on health check failures
  • โœ… Full control over runtime environment

When architecting on AWS, I learned: ECS is overkill for simple APIs (use Lambda!), but perfect for complex services! ๐Ÿณ

The Secrets Management You Actually Need ๐Ÿ”

The mistake everyone makes:

# DON'T DO THIS! ๐Ÿšจ
- name: Deploy
  env:
    AWS_ACCESS_KEY: AKIAIOSFODNN7EXAMPLE  # Hardcoded secret!
    DATABASE_URL: postgresql://user:pass@host/db  # PUBLIC IN GIT!

The proper way - GitHub Secrets:

# .github/workflows/deploy.yml
- name: Deploy
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

How to set secrets:

# Via GitHub UI:
# Repo โ†’ Settings โ†’ Secrets and variables โ†’ Actions โ†’ New repository secret

# Or via GitHub CLI:
gh secret set AWS_ACCESS_KEY_ID
gh secret set AWS_SECRET_ACCESS_KEY
gh secret set DATABASE_URL

Pro tip - Use AWS IAM roles instead of access keys:

# Better approach - OIDC (no long-lived keys!)
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v2
  with:
    role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
    aws-region: us-east-1

Why this is better:

  • โœ… No access keys to leak
  • โœ… Temporary credentials (auto-expire)
  • โœ… Fine-grained permissions
  • โœ… AWS CloudTrail audit logs

In production, I've deployed systems using OIDC. Setup takes 10 minutes, saves you from credential leaks forever! ๐Ÿ”’

GitHub Actions Deployment Patterns I Use Daily ๐Ÿ’ก

Pattern #1: Environment-Based Deployment

name: Deploy to Environments

on:
  push:
    branches: [dev, staging, main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Determine environment
        id: env
        run: |
          if [ "${{ github.ref }}" == "refs/heads/main" ]; then
            echo "env=production" >> $GITHUB_OUTPUT
            echo "function=my-api-prod" >> $GITHUB_OUTPUT
          elif [ "${{ github.ref }}" == "refs/heads/staging" ]; then
            echo "env=staging" >> $GITHUB_OUTPUT
            echo "function=my-api-staging" >> $GITHUB_OUTPUT
          else
            echo "env=dev" >> $GITHUB_OUTPUT
            echo "function=my-api-dev" >> $GITHUB_OUTPUT
          fi

      - name: Deploy to ${{ steps.env.outputs.env }}
        run: |
          aws lambda update-function-code \
            --function-name ${{ steps.env.outputs.function }} \
            --zip-file fileb://function.zip

Why this works: Same workflow, different environments. Push to dev โ†’ dev deployment. Push to main โ†’ production! ๐ŸŽฏ

Pattern #2: Blue/Green Deployment with Lambda Aliases

- name: Deploy new version
  run: |
    # Publish new version
    VERSION=$(aws lambda publish-version \
      --function-name my-api \
      --query 'Version' --output text)

    # Update "staging" alias to new version
    aws lambda update-alias \
      --function-name my-api \
      --name staging \
      --function-version $VERSION

- name: Run smoke tests
  run: npm run test:smoke

- name: Promote to production
  if: success()
  run: |
    # Only update production alias if tests pass!
    aws lambda update-alias \
      --function-name my-api \
      --name production \
      --function-version $VERSION

Result: New version goes to staging first. Tests pass? Promote to production! Tests fail? Production still on old version! ๐Ÿ›ก๏ธ

Pattern #3: Database Migrations (The Safe Way)

- name: Run database migrations
  run: |
    # Run migrations in a transaction
    npm run migrate:up

    # Verify migrations succeeded
    if [ $? -ne 0 ]; then
      echo "Migration failed! Rolling back..."
      npm run migrate:down
      exit 1
    fi

- name: Deploy application
  # Only deploys if migrations succeeded!
  run: |
    aws lambda update-function-code \
      --function-name my-api \
      --zip-file fileb://function.zip

A serverless pattern that saved us: Migrations run BEFORE deployment. If migrations fail, deployment never happens! No more broken databases! ๐Ÿ—„๏ธ

Pattern #4: Rollback on Error

- name: Deploy to Lambda
  id: deploy
  run: |
    # Get current version before deploying
    PREVIOUS_VERSION=$(aws lambda get-alias \
      --function-name my-api \
      --name production \
      --query 'FunctionVersion' --output text)

    echo "previous_version=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT

    # Deploy new version
    aws lambda update-function-code \
      --function-name my-api \
      --zip-file fileb://function.zip

- name: Health check
  run: |
    sleep 10  # Give Lambda time to warm up
    curl -f https://api.mysite.com/health || exit 1

- name: Rollback on failure
  if: failure()
  run: |
    echo "Deployment failed! Rolling back..."
    aws lambda update-alias \
      --function-name my-api \
      --name production \
      --function-version ${{ steps.deploy.outputs.previous_version }}

Why this is critical: Deployment breaks? Automatic rollback to last working version! Production stays healthy! ๐Ÿš‘

Common GitHub Actions + AWS Mistakes ๐Ÿชค

Mistake #1: Not Using Caching

Bad (slow, expensive):

- name: Install dependencies
  run: npm install  # Downloads EVERYTHING every time!

Good (fast, cheap):

- name: Setup Node.js
  uses: actions/setup-node@v3
  with:
    node-version: '18'
    cache: 'npm'  # Caches node_modules!

- name: Install dependencies
  run: npm ci  # Uses cache, 3ร— faster!

Savings: Reduced build time from 4 minutes โ†’ 90 seconds! ๐Ÿš€

Mistake #2: Deploying on Every Commit

Bad:

on:
  push:  # Deploys on EVERY push to ANY branch!

Good:

on:
  push:
    branches: [main]  # Only deploy from main branch!
  pull_request:
    types: [opened, synchronize]  # Run tests on PRs!

Why: You don't want 47 deployments from your feature branch! Only deploy from stable branches! ๐ŸŽฏ

Mistake #3: No Deployment Notifications

Bad: Deploy silently, check AWS console manually ๐Ÿ‘€

Good: Get notified when deployments happen!

- name: Notify on success
  if: success()
  run: |
    curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
      -H 'Content-Type: application/json' \
      -d '{
        "text": "โœ… Deployment to production succeeded!",
        "blocks": [{
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "*Deployment Status:* Success\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}"
          }
        }]
      }'

- name: Notify on failure
  if: failure()
  run: |
    curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
      -H 'Content-Type: application/json' \
      -d '{
        "text": "๐Ÿšจ Deployment to production FAILED!",
        "blocks": [{
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "*Deployment Status:* Failed\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}\n*Action:* Check logs immediately!"
          }
        }]
      }'

Result: Know instantly if deployments succeed or fail. No more "wait, did it deploy?" moments! ๐Ÿ“ข

Mistake #4: Not Testing Before Deployment

Bad:

- name: Deploy
  run: aws lambda update-function-code ...
# No tests! YOLO! ๐Ÿ’€

Good:

- name: Run unit tests
  run: npm test

- name: Run integration tests
  run: npm run test:integration

- name: Lint code
  run: npm run lint

- name: Type check
  run: npm run type-check

- name: Security audit
  run: npm audit --audit-level=high

- name: Deploy (only if all tests pass!)
  run: aws lambda update-function-code ...

When architecting on AWS, I learned: Every minute spent on tests saves hours debugging production! Write tests, run them in CI! ๐Ÿงช

The Cost of GitHub Actions + AWS ๐Ÿ’ธ

My production e-commerce API:

Before automation (manual deployment):

  • Developer time: 30 min/deploy ร— 20 deploys/month = 10 hours/month
  • Downtime from bad deploys: 2 hours/month
  • Mental stress: Priceless (but actually very expensive!)

After GitHub Actions automation:

  • GitHub Actions: FREE (2,000 minutes/month for private repos)
  • AWS Lambda: $12/month (1M requests)
  • S3 for artifacts: $0.50/month
  • Developer time: 0 hours/month (fully automated!)
  • Downtime: 0 hours/month (automatic rollback!)
  • Total savings: ~$2,000/month in developer time! ๐ŸŽ‰

The reality: Automation pays for itself in the FIRST deployment!

The Deployment Checklist I Use in Production โœ…

Before setting up GitHub Actions + AWS:

  • Set up AWS IAM user/role with least-privilege permissions
  • Store secrets in GitHub Secrets (never in code!)
  • Write tests (unit, integration, smoke tests)
  • Add health check endpoint to verify deployments
  • Set up rollback mechanism for failures
  • Configure notifications (Slack, email, etc.)
  • Test in staging first before production
  • Document the deployment process (for your team!)
  • Set up monitoring (CloudWatch, DataDog, etc.)
  • Create runbook for failures (what to do when things break)

Quick Start: Deploy Your First Lambda with GitHub Actions ๐Ÿš€

Step 1: Create IAM user for GitHub Actions

# Create IAM policy
aws iam create-policy \
  --policy-name GitHubActionsLambdaDeploy \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": [
        "lambda:UpdateFunctionCode",
        "lambda:GetFunction"
      ],
      "Resource": "arn:aws:lambda:*:*:function:my-api"
    }]
  }'

# Create IAM user
aws iam create-user --user-name github-actions

# Attach policy
aws iam attach-user-policy \
  --user-name github-actions \
  --policy-arn arn:aws:iam::YOUR_ACCOUNT:policy/GitHubActionsLambdaDeploy

# Create access key
aws iam create-access-key --user-name github-actions

Step 2: Add secrets to GitHub

gh secret set AWS_ACCESS_KEY_ID
gh secret set AWS_SECRET_ACCESS_KEY

Step 3: Create workflow file

# .github/workflows/deploy.yml
name: Deploy to AWS Lambda

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - run: npm ci
      - run: npm test
      - run: npm run build

      - uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - run: |
          zip -r function.zip .
          aws lambda update-function-code \
            --function-name my-api \
            --zip-file fileb://function.zip

Step 4: Push to main and watch the magic! ๐Ÿช„

The Bottom Line ๐Ÿ’ก

GitHub Actions + AWS deployment isn't just automation - it's peace of mind!

The essentials:

  1. Automate everything: No more SSH, no more manual steps
  2. Test before deploy: Catch bugs in CI, not production
  3. Rollback on failure: Always have a way back
  4. Monitor and notify: Know what's deployed, when it breaks
  5. Start simple: One workflow, one environment, iterate from there

The truth about modern deployment:

Manual deployment is technical debt. Every SSH session is risk. Every git pull in production is a potential disaster. Automation isn't "nice to have" - it's essential for professional AWS deployments!

In production, I've deployed hundreds of Lambda functions, dozens of ECS services, countless S3 sites. Every single one uses GitHub Actions now. Zero regrets! The first deploy takes 30 minutes to set up. Every deploy after that is free, fast, and fearless! ๐Ÿš€

You don't need perfect CI/CD from day one - you need AUTOMATED deployment that prevents disasters! And GitHub Actions + AWS gives you that! โ˜๏ธ

Your Action Plan ๐ŸŽฏ

This week:

  1. Set up IAM user for GitHub Actions
  2. Add AWS credentials to GitHub Secrets
  3. Write your first deployment workflow
  4. Test in staging environment

This month:

  1. Add automated tests to workflow
  2. Implement blue/green deployments
  3. Set up rollback mechanism
  4. Add deployment notifications

This quarter:

  1. Automate database migrations
  2. Implement multi-environment deployments
  3. Add comprehensive monitoring
  4. Become the CI/CD champion on your team! ๐Ÿ†

Resources Worth Your Time ๐Ÿ“š

Tools I use daily:

Reading list:

Real talk: The best deployment is the one you don't have to think about! Set it up once, trust it forever!


Still manually deploying to AWS? Connect with me on LinkedIn and share your deployment automation wins!

Want to see my CI/CD setups? Check out my GitHub - I've open-sourced my deployment workflows!

Now go forth and automate those deployments! ๐Ÿš€โ˜๏ธ


P.S. If you're still SSH-ing into production servers to deploy, I'm not judging you (okay, maybe a little). We've all been there! But seriously, set up GitHub Actions this week. Future you will be VERY grateful! ๐Ÿ™

P.P.S. I once spent 3 hours debugging a production issue that turned out to be "I deployed to the wrong server." GitHub Actions eliminates these facepalm moments. You're welcome! ๐Ÿ˜