Skip to content

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.

ApproachActions-based (earlier chapter)Raw SSH (this chapter)
Copy stepappleboy/scp-actionrsync over ssh directly
Remote commandsappleboy/ssh-actionssh ... << 'EOF' ... EOF heredoc
Web serverPython http.server + systemd unitnginx (installed by the workflow)
Host key handlingHandled inside the actionYou populate ~/.ssh/known_hosts yourself
Third-party actions20

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.html

Same 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 .pem private key
  • KNOWN_HOSTS (secret) — covered in its own section below
  • EC2_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.**
          EOF

Walking 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:

  1. EC2 console → Instances, select the instance, Instance state → Terminate.
  2. Delete the key pair associated with it.
  3. Delete the security group, once the instance has finished terminating.
Last updated on