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:
- EC2 console → Launch instance. Pick an Amazon Linux 2023 or Ubuntu AMI,
t3.micro(ort2.microif you’re on the free tier). - Key pair: create a new one and download the
.pemfile — you’ll need its contents, not just its name. - 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. - 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 IPEC2_USER—ec2-userfor Amazon Linux, orubuntufor 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.targetsudo systemctl daemon-reload
sudo systemctl enable --now devstuffs-siteNote
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-siteTwo separate actions, doing two separate jobs, both talking to the same box over the same SSH key:
scp-actioncopies the file itself. It can’t drop files directly into/var/www/devstuffsbecause that step connects as your regular EC2 user, and that user doesn’t have write access there withoutsudo— so it lands in/tmpfirst.ssh-actionruns arbitrary remote commands. Here, it moves the file into its real location withsudoand 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:
- EC2 console → Instances, select your instance, Instance state → Terminate.
- 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.
- 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.