Deploying with GitHub Actions
So far your workflow has tested and built your code. The last step is deployment — getting that code running on a real server where users can reach it. In this page you will add a deploy job to a GitHub Actions workflow. We will cover two of the most common patterns: connecting to an Ubuntu server over SSH to pull and restart your app, and building a Docker image and pushing it to a registry. Doing this with GitHub Actions means every push to your main branch can ship to production with zero manual steps — which is the heart of CD (Continuous Deployment, the practice of automatically releasing every passing build).
How a deploy job fits in
A GitHub Actions workflow is made of jobs (groups of steps that run on a fresh virtual machine). Earlier jobs run your tests and build artifacts. A deploy job should run only after those succeed, so we use the needs keyword to make it wait. We also guard it with an if condition so it only runs on the main branch — you never want a feature branch deploying to production.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "run your tests here"
deploy:
needs: test # wait for test job to pass
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: echo "deploy steps go here"
When to use this: Use a deploy job when you have a server or registry that GitHub’s runners can reach over the network. If your server sits behind a firewall with no public SSH access, deploy-from-CI will not work without a self-hosted runner or a VPN — in that case a pull-based tool (the server polls for changes) is a better fit.
Pattern 1 — deploy over SSH
The simplest deploy is to SSH (Secure Shell — an encrypted way to log into a remote machine and run commands) into your Ubuntu server, pull the latest code with git, and restart the service. GitHub Actions never stores your password; instead it uses an SSH key pair (a private key you keep secret and a public key you put on the server).
Step 1: create a deploy key on your local machine
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""
Output:
Generating public/private ed25519 key pair.
Your identification has been saved in deploy_key
Your public key has been saved in deploy_key.pub
This makes two files: deploy_key (private — goes into GitHub) and deploy_key.pub (public — goes on the server).
Step 2: install the public key on your Ubuntu server
Copy the public key into the server user’s authorized_keys file so that anyone holding the private key may log in:
ssh-copy-id -i deploy_key.pub deployer@your-server-ip
If ssh-copy-id is not available, append it manually:
cat deploy_key.pub | ssh deployer@your-server-ip 'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys'
Step 3: store the private key as a secret
A secret is an encrypted value GitHub stores for you and injects into a workflow at runtime — it never appears in logs. In your repository, go to Settings → Secrets and variables → Actions → New repository secret and add:
| Secret name | Value |
|---|---|
SSH_PRIVATE_KEY | the full contents of the deploy_key file |
SERVER_HOST | your server’s IP or domain, e.g. 203.0.113.10 |
SERVER_USER | the SSH user, e.g. deployer |
Security gotcha: Never paste a private key directly into the YAML file or commit it to git. Anyone with read access to the repo (or the commit history) would gain full server access. Always use secrets, and use a dedicated, low-privilege
deployeruser — notroot.
Step 4: the deploy job
The community action appleboy/ssh-action handles connecting and running commands for you:
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/myapp
git pull origin main
npm ci --omit=dev
sudo systemctl restart myapp
Here systemctl restart myapp restarts a systemd service (systemd is the Ubuntu process manager that keeps your app running and starts it on boot). When the workflow runs, the deploy step logs each command:
Output:
======CMD======
cd /var/www/myapp
git pull origin main
npm ci --omit=dev
sudo systemctl restart myapp
======END======
Already up to date.
added 142 packages in 3s
==============================================
✅ Successfully executed commands to all hosts.
Pattern 2 — build and push a Docker image
Instead of pulling source on the server, you can build a Docker image (a packaged, ready-to-run snapshot of your app) in CI and push it to a container registry (a storage service for images). GitHub provides its own registry, GHCR (GitHub Container Registry, at ghcr.io), and authenticates automatically using the built-in GITHUB_TOKEN.
build-and-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # needed to push to GHCR
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
Your server then runs docker pull ghcr.io/youruser/yourrepo:latest && docker compose up -d, either by hand or via the SSH job above.
Environments and approvals
GitHub Environments let you scope secrets and add a manual gate before a deploy runs. This is how you require a human to click Approve before production ships — useful when you want CD for staging but a review for production.
Create one under Settings → Environments → New environment (e.g. production), add Required reviewers, then reference it:
deploy:
needs: test
runs-on: ubuntu-latest
environment: production # pauses until a reviewer approves
steps:
- run: echo "deploying to production"
| No environment | Environment with reviewers | |
|---|---|---|
| Speed | Instant on push | Waits for human approval |
| Secrets | Repo-wide | Scoped to that environment |
| Best for | Staging / dev | Production |
Best Practices
- Always gate deploys with
needs:andif: github.ref == 'refs/heads/main'so only tested code on the main branch ships. - Use a dedicated, non-root
deployeruser with the minimum permissions it needs — never deploy asroot. - Store every credential as a secret; pin actions to a major version (
@v4) to avoid supply-chain surprises. - Use Environments with required reviewers for production, and let staging deploy automatically.
- Tag Docker images with the commit SHA (
${{ github.sha }}) as well aslatest, so you can roll back to an exact build. - Keep deploy scripts idempotent (safe to re-run):
git pull,npm ci, andsystemctl restartall behave the same if run twice.