TechEarl

Push-to-Deploy with GitHub Actions

A beginner-friendly intro to CI/CD with GitHub Actions: a real workflow YAML that builds and deploys on every push to main, plus how to handle secrets safely.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
How to set up push-to-deploy with GitHub Actions: a CI/CD workflow that builds and deploys on every push to main.

Push-to-deploy means that when you push to your main branch, your code builds and ships to your server automatically, with no manual steps. With GitHub Actions you write a small YAML file describing the steps (install, test, build, deploy), commit it to your repo, and GitHub runs it on every push. No deploy scripts to run by hand, no SSH-and-pray.

This is the gentlest possible on-ramp to CI/CD. If you can read a package.json and you already know how to push to a remote, you have everything you need.

What is CI/CD, in one paragraph?

CI (continuous integration) means every change you push gets built and tested automatically, so you find out it's broken in minutes instead of when a teammate pulls it. CD (continuous deployment) means a change that passes those checks goes straight to your server with no human in the loop. GitHub Actions does both: it gives you a fleet of throwaway Linux, macOS, or Windows machines (called runners) that wake up on an event, run your steps, and disappear.

The event we care about here is "someone pushed to main". The steps are "build it, then deploy it".

The mental model: events, jobs, and steps

A GitHub Actions workflow is a YAML file that lives in your repo at .github/workflows/. Three nested concepts:

  • An event triggers the workflow (a push, a pull request, a schedule, a manual click).
  • A job runs on one fresh runner machine. A workflow can have several jobs that run in parallel or in sequence.
  • A step is a single command or a reusable action. Steps run top to bottom inside a job.

That is the whole model. The rest is filling in the steps.

A workflow that builds and deploys on push to main

Here is a complete, real workflow for a Node.js project. Save it as .github/workflows/deploy.yml and commit it. The moment it lands on main, GitHub starts running it on every subsequent push.

yaml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --omit=dev
            pm2 restart myapp

Reading it top to bottom:

  • on: push: branches: [main] is the trigger. Pushes to any other branch are ignored, so feature branches and pull requests don't deploy.
  • runs-on: ubuntu-latest asks for a fresh Ubuntu runner.
  • actions/checkout@v4 clones your repo onto the runner. Without it, the runner is an empty machine.
  • actions/setup-node@v4 installs the Node version you ask for.
  • npm ci installs from package-lock.json exactly (cleaner and faster than npm install in CI).
  • The deploy step SSHes into your server and runs the update commands there. If the server's checkout has drifted (someone edited a file in place), that git pull origin main can fail the same way pushing to a branch that GitHub rejects does, so keep the server's working tree clean and let the workflow own it.

If npm test or npm run build fails, the workflow stops and the deploy step never runs. That is the safety net: a broken build does not reach production.

For a deeper walkthrough of just the Node setup half of this (caching node_modules, matrix builds across Node versions), I wrote a focused companion piece on setting up Node in a GitHub Actions workflow.

Secrets: never put credentials in the YAML

Notice the deploy step never contains a password or a private key. It reads ${{ secrets.DEPLOY_SSH_KEY }} and friends. Those values live in your repo's encrypted secret store, not in the file. This matters: the workflow YAML is committed to the repo, so anything written there is visible to anyone who can read the code, and it stays in history forever.

Add a secret in the GitHub UI under Settings → Secrets and variables → Actions → New repository secret. Give it a name (DEPLOY_SSH_KEY), paste the value, save. The runner can read it at run time; nobody can read it back out of the UI, and GitHub masks it in the logs if it ever gets printed.

A few rules I follow:

  • One secret per credential. Don't stuff a whole .env file into a single secret.
  • Generate a deploy-only SSH key, not your personal one. If you have not made an SSH key before, my guide on adding an SSH key to GitHub covers the ssh-keygen half; for a deploy key you put the public half on the server's authorized_keys and the private half in the GitHub secret. This is also part of choosing SSH over HTTPS for the deploy remote: a key the runner holds beats prompting for a password the runner cannot answer.
  • If a credential ever lands in a commit by accident, rotate it immediately and scrub it. Encrypted secrets do not help you if the real value is already in your Git history, and a leaked secret in history is a live exposure until you remove it and revoke it.

How this replaces manual deploys

Before Actions, my deploy ritual was: SSH into the box, git pull, reinstall dependencies, restart the process, watch the logs, hope I did the steps in the right order at 11pm. The failure modes were all human: forgot a step, deployed an untested branch, fat-fingered a command on the wrong server.

The workflow file makes the deploy reproducible. The steps are the same every time because a machine runs them. Here is the difference laid out:

Manual deployPush-to-deploy with Actions
You SSH in and run commands by handA runner runs the committed steps
Steps live in your head or a wikiSteps live in deploy.yml, versioned with the code
Tests are "I'll run them if I remember"Tests gate the deploy automatically
Easy to deploy the wrong branchOnly main triggers it
No record of what happenedEvery run is logged with timestamps and output

The second column is not more powerful, it is just honest. The machine cannot skip the test step because it is tired.

A simpler first version

If "SSH into a server" is more than your project needs yet, you can prove the concept with a workflow that just builds and reports success. Push this, break a test on purpose, and watch the red X appear in the Actions tab:

yaml
name: CI

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test

Start here. Add the deploy step once the build step is green and you trust it.

A note on protecting main

Push-to-deploy makes your main branch load-bearing: whatever lands there ships. That is an argument for not pushing directly to main at all once a team is involved. Route changes through pull requests (here is how I go about opening a pull request), let the CI workflow run on the PR, and only merge when it is green. The same hygiene that keeps main shippable extends to writing a clear commit history, so the log of what shipped reads cleanly later. GitHub's branch protection can require that. I cover the settings in protecting your main branch on GitHub, and the team conventions around it in Git workflows for teams.

There is also an older, server-side way to do push-to-deploy without any CI service at all: a bare repo with a post-receive hook. If you have never touched a server-side hook that runs on every push, that primer is the place to start. Actions is the better default in 2020 for most projects, but the hook approach is worth knowing, especially on a box you fully control.

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsGitHub Actions deployCI/CDGitHub ActionsContinuous DeploymentGitVersion Control

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Undo the Last Git Commit

Undo your last Git commit without losing work. When to use amend, reset --soft, reset --mixed, reset --hard, and revert, plus the rule for commits you already pushed.