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.
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 myappReading 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-latestasks for a fresh Ubuntu runner.actions/checkout@v4clones your repo onto the runner. Without it, the runner is an empty machine.actions/setup-node@v4installs the Node version you ask for.npm ciinstalls frompackage-lock.jsonexactly (cleaner and faster thannpm installin 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 maincan 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
.envfile 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-keygenhalf; for a deploy key you put the public half on the server'sauthorized_keysand 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 deploy | Push-to-deploy with Actions |
|---|---|
| You SSH in and run commands by hand | A runner runs the committed steps |
| Steps live in your head or a wiki | Steps 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 branch | Only main triggers it |
| No record of what happened | Every 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:
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 testStart 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.
- Workflow syntax for GitHub Actions | GitHub Docsdocs.github.com
- Using secrets in GitHub Actions | GitHub Docsdocs.github.com
- Understanding GitHub Actions | GitHub Docsdocs.github.com





