Skip to content

Deploying to EC2 Instance Over SSH


The last two chapters handed hosting off to a managed service that never actually gives you a shell — S3 stores files, CloudFront serves them, and you never once SSH into anything. This chapter is the opposite: a real EC2 instance, a real Linux user, and a workflow that logs in and pushes a file exactly the way you would by hand, just automated.

The Site, One More Time

Same page, same #target trick, now stamped AWS EC2:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>devstuffs CI/CD practice</title>
  <style>
    body { font-family: system-ui, sans-serif; background: #0B0E14; color: #E4E2DC; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
    .card { text-align: center; }
    code { color: #E8A33D; }
  </style>
</head>
<body>
  <div class="card">
    <h1>Hello from devstuffs 👋</h1>
    <p>This page was deployed by GitHub Actions.</p>
    <p><code id="target">deployment target: unknown</code></p>
  </div>
</body>
</html>

Provisioning the Instance

This chapter’s focus is the CI deploy step, not EC2 fundamentals, so keep provisioning brief:

  1. EC2 console → Launch instance. Pick an Amazon Linux 2023 or Ubuntu AMI, t3.micro (or t2.micro if you’re on the free tier).
  2. Key pair: create a new one and download the .pem file — you’ll need its contents, not just its name.
  3. Security group: allow inbound SSH (port 22) from your IP, and HTTP (port 80) from anywhere (0.0.0.0/0), since that’s what will actually serve the page.
  4. Launch it, wait for it to reach “Running,” and note its public IPv4 address.

Getting an SSH Key Into GitHub Secrets

The .pem file you downloaded is the private key — copy its full contents (including the -----BEGIN... and -----END... lines) into a repo secret named EC2_SSH_KEY. Also add:

  • EC2_HOST — the instance’s public IP
  • EC2_USER — ec2-user for Amazon Linux, or ubuntu for an Ubuntu AMI

The systemd Service

python3 -m http.server 80 run directly in a terminal dies the moment you close that terminal, and doesn’t come back after a reboot. A systemd unit fixes both problems — SSH in once, manually, to set this up before the workflow ever runs:

sudo mkdir -p /var/www/devstuffs

/etc/systemd/system/devstuffs-site.service:

[Unit]
Description=devstuffs static site (practice)
After=network.target

[Service]
WorkingDirectory=/var/www/devstuffs
ExecStart=/usr/bin/python3 -m http.server 80
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now devstuffs-site

Note

Binding to port 80 requires root, and this unit runs as root by default since no User= line is set. That’s fine for a short-lived practice box serving one static file with Python’s built-in server — it is not how you’d run a real production site, but teaching a hardened reverse-proxy setup is out of scope for what this chapter is demonstrating.

Confirm it’s alive before touching CI at all: curl http://<your-instance-ip> should return whatever placeholder index.html you left in /var/www/devstuffs (even an empty directory will return a 404 politely — that’s still confirmation the service itself is running).

The Workflow

name: Deploy to EC2 over SSH

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: aws-ec2
      url: http://${{ secrets.EC2_HOST }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Stamp deployment target
        run: sed -i 's/deployment target: unknown/deployment target: AWS EC2/' index.html

      - name: Copy site to EC2
        uses: appleboy/scp-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USER }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: "index.html"
          target: "/tmp/devstuffs-deploy"

      - name: Move file into place and restart service
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USER }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            sudo mv /tmp/devstuffs-deploy/index.html /var/www/devstuffs/index.html
            sudo systemctl restart devstuffs-site

Two separate actions, doing two separate jobs, both talking to the same box over the same SSH key:

  • scp-action copies the file itself. It can’t drop files directly into /var/www/devstuffs because that step connects as your regular EC2 user, and that user doesn’t have write access there without sudo — so it lands in /tmp first.
  • ssh-action runs arbitrary remote commands. Here, it moves the file into its real location with sudo and restarts the service — the restart isn’t strictly required for a static file (the running server reads the file fresh on every request), but it’s included as a deliberate, visible confirmation step, and it’s exactly what you’d add if this were serving something that did need a restart to pick up changes.

Warning

Both actions receive the same EC2_SSH_KEY secret plainly as an input. GitHub automatically masks secret values in workflow logs, but keep in mind that anything you pass as script: here does become part of the workflow run’s history — never build the script: block by interpolating secret values into a shell string.

Verifying the Deployment

Visit http://<your-instance-ip> (or the URL on the environment: block). You should see deployment target: AWS EC2. If the connection just hangs, it’s almost always the security group — double-check port 80 is actually open to the address you’re browsing from.

Cleanup

Like the S3 and CloudFront resources from the last chapter, a running EC2 instance costs money every hour, even idle:

  1. EC2 console → Instances, select your instance, Instance state → Terminate.
  2. Delete the key pair you created for it (Key Pairs in the EC2 console), since the private half no longer has a matching instance to unlock.
  3. If you created a security group solely for this exercise (rather than reusing a default one), delete it too — EC2 won’t let you delete a security group still attached to a running instance, so this has to happen after termination finishes, not before.
Last updated on