Template Catalog
L3CI/CDIntermediate

GitHub Actions Deploy to EC2

Ship on every push instead of deploying by hand.

Overview

Your first real CI/CD pipeline. On every push to main, GitHub Actions builds the Docker image, then connects to the EC2 instance over SSH and redeploys the container. Secrets live in GitHub, not in your shell history. Deployment stops being a thing you do and becomes a thing that happens.

Architecture

git push
GitHub Actions runner
GitHub Secrets
EC2 instance
Docker container

A push to main triggers a GitHub Actions workflow. The runner builds the image, copies it (or pulls it) to the instance over SSH, and restarts the container. Credentials come from GitHub Secrets.

  1. 1You push to the main branch.
  2. 2The workflow checks out code and builds the Docker image.
  3. 3Using an SSH key from Secrets, the runner connects to the instance.
  4. 4It loads/rebuilds the image and restarts the container.

A push to GitHub triggers an Actions runner that builds a Docker image and connects over SSH to an EC2 instance to redeploy the container, using GitHub Secrets for credentials.

What you'll understand

  • Understand the anatomy of a CI/CD pipeline: triggers, jobs, steps.
  • Store and use secrets safely with GitHub Encrypted Secrets.
  • Build a Docker image in CI and deploy it over SSH.
  • Recognize the limits of building-on-push and SSH deploys (motivates Level 4).

Prerequisites

  • Completed Level 2 (Terraform EC2)
  • A GitHub repository for your app
  • An SSH key whose public half is on the EC2 instance
  • The instance reachable on port 22 from GitHub runners (or a self-hosted runner)

Generated files

The files this template produces. Copy any of them straight into your project.

1 files

Builds the image and deploys to EC2 over SSH on every push to main.

.github/workflows/deploy.yml
yaml
name: Deploy to EC2

on:
  push:
    branches: [main]

# Least privilege: this workflow only needs to read the repo.
permissions:
  contents: read

concurrency:
  group: deploy-ec2
  cancel-in-progress: true

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

      - name: Build image
        run: docker build -t web:${{ github.sha }} .

      - name: Save image to a tarball
        run: docker save web:${{ github.sha }} | gzip > web.tar.gz

      - name: Copy image to the instance
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ec2-user
          key: ${{ secrets.EC2_SSH_KEY }}
          source: web.tar.gz
          target: /home/ec2-user

      - name: Load and restart on the instance
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ec2-user
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            gunzip -f web.tar.gz
            docker load -i web.tar
            docker stop web || true && docker rm web || true
            docker run -d --name web -p 80:80 --restart unless-stopped web:${{ github.sha }}

Step-by-step guide

  1. 1

    Add a Dockerfile

    Your app needs to build into an image. A minimal, multi-stage Dockerfile keeps the result small and reproducible.

  2. 2

    Create the GitHub Secrets

    In repo Settings → Secrets and variables → Actions, add EC2_HOST (the public IP/DNS) and EC2_SSH_KEY (the private key). These are encrypted and never printed in logs.

    Use a deploy-only key, not your personal SSH key.

  3. 3

    Commit the workflow

    Drop deploy.yml into .github/workflows/. Note the `permissions: contents: read` line — the workflow gets only what it needs.

    git add .github/workflows/deploy.yml && git commit -m 'ci: deploy on push'
  4. 4

    Push and watch it run

    Push to main and open the Actions tab. Watch the build → copy → restart steps. Your first automated deploy.

    git push origin main

AI insight

Ask the assistant to explain, review, or recommend — authored for this template.

AI insightAuthored

What this pipeline is doing

A push to main triggers a runner that builds your image, then connects to the instance over SSH and restarts the container. Credentials come from GitHub's encrypted Secrets, not your laptop.

  • Trigger: push to main.
  • Build on the runner, ship to the box over SSH.
  • Secrets are encrypted and masked in logs.

Security notes

  • Secrets are out of your shell

    Info

    Credentials live in GitHub Encrypted Secrets and are masked in logs — a real improvement.

  • Workflow permissions are scoped

    Info

    Setting `permissions: contents: read` follows least privilege for the GITHUB_TOKEN.

  • SSH open to GitHub's runner IPs

    Medium

    Hosted runners use a wide IP range, so 'restrict SSH' is hard without a self-hosted runner or bastion.

    Level 4 removes server-side building; later you can deploy via SSM with no inbound SSH at all.

  • Image is rebuilt every deploy

    Low

    Building in CI and shipping a tarball works but isn't an auditable, versioned artifact.

    Level 4 pushes immutable images to ECR instead.

Cost notes

Low cost~$8/mo~$0.01/hr if left running · free-tier eligible
  • GitHub Actions minutes

    Free tier includes 2,000 minutes/month for private repos; public repos are free.

  • EC2 instance

    Unchanged from earlier levels — the pipeline deploys to the same box.

Cleanup guide

Tear it down when you're done — the fastest way to avoid a surprise bill.

  1. 1

    Disable or delete the workflow if you stop using the repo.

  2. 2

    Rotate or remove the deploy SSH key from the instance and Secrets.

  3. 3

    Terminate the EC2 instance (terraform destroy if you're on Level 2).

    Billing

Troubleshooting

Where to go next