Deploying Nginx to EC2
The EC2 chapter earlier in this section leaned on two things to keep the workflow simple: a couple of well-maintained third-party actions (appleboy/scp-action, appleboy/ssh-action) and Python’s own http.server running under systemd. Neither of those choices is wrong, but neither is the only way to do it, either. This is the other real way people wire up an EC2 deploy: plain ssh/rsync commands with no third-party actions at all, serving the page with nginx instead of a one-file Python server. Same destination, same artifact, genuinely different mechanics — and different gotchas to go with them.
Why Skip the Actions This Time
GitHub’s Ubuntu runners already ship with ssh, scp, and rsync preinstalled — you don’t strictly need an action to drive any of them, the same way you’ve never needed an action to run curl or grep in a run: step. Dropping the third-party actions means one less thing to version-pin and trust, at the cost of writing the SSH plumbing — key handling, host verification, the remote command itself — by hand instead of letting an action’s with: block manage it for you. Neither trade-off is free; this chapter shows you the manual side of it.
| Approach | Actions-based (earlier chapter) | Raw SSH (this chapter) |
|---|---|---|
| Copy step | appleboy/scp-action | rsync over ssh directly |
| Remote commands | appleboy/ssh-action | ssh ... << 'EOF' ... EOF heredoc |
| Web server | Python http.server + systemd unit | nginx (installed by the workflow) |
| Host key handling | Handled inside the action | You populate ~/.ssh/known_hosts yourself |
| Third-party actions | 2 | 0 |
The Project Layout
This chapter’s repo keeps the site under a _site/ folder rather than at the repo root — a small, deliberate convention for this version, since it makes the rsync source and destination read cleanly as “sync this directory into that directory” rather than “sync the whole repo, config files and all”:
.
└── _site/
└── index.htmlSame artifact as every other chapter in this section, just relocated:
<!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 itself doesn’t need to differ from the earlier chapter’s — same t3.micro/t2.micro, same security group opening 22 (SSH) and 80 (HTTP) — you can reuse that instance if it’s still running, or launch a fresh one the same way. The one thing this version doesn’t need is a hand-written systemd unit: the workflow installs nginx directly via apt-get, and nginx registers and enables its own systemd service the moment it’s installed, so there’s no unit file to author yourself this time.
Secrets, and One Deliberate Non-Secret
Four values feed this workflow, and they’re split across secrets and repo variables on purpose:
EC2_USER(secret) — the SSH login user (ubuntu, typically, for an Ubuntu AMI)EC2_SSH_KEY(secret) — the full contents of your.pemprivate keyKNOWN_HOSTS(secret) — covered in its own section belowEC2_HOST(repo variable, not a secret) — the instance’s public IP
That last one is the deliberate choice: EC2_HOST is the address you want people to visit, so there’s nothing to protect by hiding it — treating it as a secret would just mean GitHub masks it everywhere, including in the deployment summary where you actually want it visible as a clickable link. Anything a visitor is meant to type into their own browser isn’t a secret; only the credentials that get you onto the box are.
Getting a known_hosts Entry
SSH refuses to connect to a host it’s never seen before without some proof the host is who it claims to be — normally you’d hit an interactive yes/no prompt for this the first time you SSH in by hand, but a non-interactive workflow has no one to answer that prompt. The fix isn’t to disable the check — it’s to supply the answer ahead of time.
From a machine you already trust (your own laptop, not the CI runner), generate the host’s public key fingerprint:
ssh-keyscan -H <ec2-public-ip>The -H flag hashes the hostname in the output rather than storing it as plain text. Paste the result into a repo secret named KNOWN_HOSTS.
Warning
Generate this from a machine you trust, not from inside the workflow itself. If a workflow run generated its own known_hosts entry on the fly — or worse, disabled the check entirely with -o StrictHostKeyChecking=no — it would blindly trust whatever answered on that IP at that moment, which is exactly the opening a DNS-spoofing or man-in-the-middle attack needs. Generating it once, from somewhere you already trust, and storing the result is what actually closes that gap.
The Workflow
name: Deploy on EC2
on: [workflow_dispatch, push]
jobs:
deploy-aws-ec2:
runs-on: ubuntu-latest
env:
EC2_USER: ${{ secrets.EC2_USER }}
EC2_HOST: ${{ vars.EC2_HOST }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }}
environment:
name: aws-ec2
url: http://${{ env.EC2_HOST }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Stamp deployment target
run: |
sed -i 's/deployment target: unknown/deployment target: AWS EC2/' _site/index.html
- name: Copy files to AWS EC2
shell: bash
run: |
echo "$EC2_SSH_KEY" > key.pem && chmod 400 key.pem
mkdir -p ~/.ssh/ && touch ~/.ssh/known_hosts
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
rsync -avz -e "ssh -T -i key.pem" _site/ "${EC2_USER}@${EC2_HOST}":html/
- name: Deploy on AWS EC2
shell: bash
run: |
# EOF is quoted so local variables are not expanded before they reach the remote shell
ssh -T -i key.pem "${EC2_USER}@${EC2_HOST}" << 'EOF'
if ! command -v nginx > /dev/null 2>&1; then
sudo apt-get update && sudo apt-get install nginx -y
sudo systemctl enable --now nginx
fi
sudo rsync -a --delete html/ /var/www/html/
EOF
- name: Write deployment summary
shell: bash
run: |
# EOF is left unquoted here specifically so $EC2_HOST is expanded
cat << EOF >> "$GITHUB_STEP_SUMMARY"
### 🚀 Deployment Status
| Deployed To | Access At |
| ----------- | --------- |
| AWS EC2 | [http://$EC2_HOST](http://$EC2_HOST) |
**Click the URL to access the application.**
EOFWalking Through the Rough Edges
A handful of details in this workflow exist specifically because of mistakes that are easy to make the first time you write raw SSH steps by hand — each is worth understanding on its own, not just copying past.
ssh Host key verification failed. GitHub-hosted runners start with no ~/.ssh/ directory at all, so the very first thing the “Copy files” step does is create one and populate known_hosts with the value generated earlier. Skip this and every ssh or rsync -e ssh call in the job fails before it even authenticates.
scp: path canonicalization failed (why this workflow uses rsync, not scp). As of OpenSSH 9.0, plain scp switched to using the SFTP protocol underneath by default, and SFTP is stricter about an important detail: it refuses to write into a remote directory that doesn’t already exist, where legacy scp would happily create it. Hit that error with real scp and you have three ways out — force the old protocol with scp -O, ssh in first to mkdir -p the destination, or do what this workflow does and use rsync -e ssh instead, which creates a missing leaf directory as part of the sync rather than requiring it up front.
Pseudo-terminal will not be allocated because stdin is not a terminal. This is a warning, not a failure, but it’s noisy in workflow logs and worth silencing deliberately rather than ignoring: -T tells ssh not to bother requesting a terminal, which is correct for a non-interactive script that isn’t expecting to interact with a shell prompt. (If a future script genuinely needs an interactive-style remote command — sudo prompting for a password, for instance — -t or -tt forces a terminal instead; this workflow’s commands don’t need one.)
Two different heredoc quoting styles, on purpose. The “Deploy on AWS EC2” step uses << 'EOF' — quoted, so nothing local gets expanded before the block is sent to the remote shell; every $ in there belongs to the remote machine. The final summary step uses << EOF — unquoted, specifically because it needs $EC2_HOST expanded locally, before it’s written into the step summary. Getting this backwards is a common, quiet bug: an unquoted heredoc talking to a remote shell can leak local variables into remote commands that were never meant to see them, and a quoted heredoc used for local output just prints the literal string $EC2_HOST instead of the value.
Why EC2_HOST isn’t a secret. GitHub automatically masks secret values anywhere they’d appear in logs or outputs — including a step summary. Since the summary step’s entire purpose is producing a clickable link to the live site, a masked EC2_HOST would make that link literally unusable. The underlying rule generalizes past this one workflow: if a value is going to be logged or displayed on purpose, it was never a secret to begin with — anything that actually needs to stay hidden should never be routed anywhere it’d get printed in the first place.
Verifying the Deployment
Run the workflow manually from the Actions tab (workflow_dispatch, no push trigger this time), then check the run’s Summary tab — you’ll see the rendered table from the last step with a live link straight to http://<your-instance-ip>. Open it and confirm the card reads deployment target: AWS EC2, same as the systemd version earlier in this section, just served by nginx underneath instead of http.server.
Cleanup
Same AWS resources, same reason to tear them down when you’re done — a running EC2 instance bills by the hour whether or not nginx has any real traffic to serve:
- EC2 console → Instances, select the instance, Instance state → Terminate.
- Delete the key pair associated with it.
- Delete the security group, once the instance has finished terminating.