Skip to content
DevOps devops cicd 5 min read

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 nameValue
SSH_PRIVATE_KEYthe full contents of the deploy_key file
SERVER_HOSTyour server’s IP or domain, e.g. 203.0.113.10
SERVER_USERthe 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 deployer user — not root.

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 environmentEnvironment with reviewers
SpeedInstant on pushWaits for human approval
SecretsRepo-wideScoped to that environment
Best forStaging / devProduction

Best Practices

  • Always gate deploys with needs: and if: github.ref == 'refs/heads/main' so only tested code on the main branch ships.
  • Use a dedicated, non-root deployer user with the minimum permissions it needs — never deploy as root.
  • 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 as latest, so you can roll back to an exact build.
  • Keep deploy scripts idempotent (safe to re-run): git pull, npm ci, and systemctl restart all behave the same if run twice.
Last updated June 15, 2026
Was this helpful?