LOGO
Core Dynexiz Technology • Education • Innovation
Core Dynexiz

How to Set Up CI/CD with GitHub Actions | Step-by-Step

Home / How to Set Up CI/CD with GitHub Actions | Step-by-Step

How to Set Up CI/CD Using GitHub Actions

A GitHub Actions CI/CD pipeline automates code checks, testing, builds and deployment. This step-by-step guide explains how to configure your repository, SSH access and workflow to deploy an application to a Linux server.

GitHub Actions CI/CD pipeline showing code, build, test and deployment
Code changes move through automated checks before deployment to the server.

This guide builds a practical pipeline for a JavaScript application deployed to a Linux server over SSH. Pull requests run validation. Validated main-branch pushes can deploy to production.

The example assumes Ubuntu or Debian, an existing Apache or Nginx installation, SSH port 22, a committed npm lockfile and a static frontend that produces dist/. Replace the example hostname, user and deployment path with your own values.

1. Understand Your GitHub Actions CI/CD Pipeline

Term Purpose
Continuous Integration Install dependencies, lint, run automated tests and verify the build.
Continuous Delivery Prepare validated changes for a release that includes approval.
Continuous Deployment Automatically release changes after the required checks pass.

A GitHub Actions workflow responds to repository events. Jobs run on runners, and each job executes a sequence of steps. A step can run commands or use a reusable action.

To see how automated releases fit into a broader development process, read our software development life cycle guide.

2. Prepare the Project Locally

Local machine. Before creating your GitHub Actions CI/CD pipeline, confirm that installation, testing and build commands work locally. This article assumes your package.json defines lint, test:ci and build scripts.

npm ci
npm run lint
npm run test:ci
npm run build

Configure test:ci for your test framework so it runs once and exits with a failure status if a test fails. Commit package-lock.json. The workflow uses Node 22, so confirm that your project supports that runtime.

Project Typical output
Vite frontend dist/ from npm run build
Create React App build/ from npm run build
Nuxt static site .output/public/ from npm run generate
Laravel backend Application code with server-side Composer and Artisan release steps

The workflow below uses dist/. Change both the build-output check and upload source if your framework uses a different directory.

3. Prepare the Server and Back Up the Site

Server administrator session. Create a dedicated deployment user and directory for a fresh static-site installation.

sudo adduser --disabled-password --gecos "" deploy_example
sudo install -d -m 755 -o deploy_example -g www-data \
  /var/www/example.com/public
sudo apt-get install -y rsync

Configure Apache or Nginx to serve this directory. For client-side routing, configure an index.html fallback for application routes. Verify web-server read access.

For an existing site, create a backup before replacing content or changing ownership.

sudo install -d -m 700 /var/backups/example
sudo tar -czf \
  "/var/backups/example/public-$(date +%Y%m%d-%H%M%S).tar.gz" \
  -C /var/www/example.com public

If an existing static directory belongs to another user, assign this exact deployment tree to the deployment account and check all nested directories.

sudo chown -R deploy_example:www-data /var/www/example.com/public
sudo find /var/www/example.com/public -type d -exec chmod 755 {} +
sudo find /var/www/example.com/public -type f -exec chmod 644 {} +
sudo -u deploy_example find /var/www/example.com/public \
  -type d ! -writable -print

The last command should return no paths. Backend applications need their own runtime-storage permissions, so adapt the static-file example for those projects.

For WordPress deployments, review our WordPress website security guide when preparing the site and its access controls.

4. Configure SSH Authentication

Local machine. Generate a dedicated key pair. If the filename already exists, inspect the existing pair before overwriting it.

mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t ed25519 -N "" \
  -f ~/.ssh/github_actions_example -C "github-actions:example"
File Use
github_actions_example Private key saved as a GitHub repository secret
github_actions_example.pub Public key added to the server deployment account

The empty passphrase allows the automation key to load without a prompt. Keep its private content out of Git, logs and messages.

Read the public key on your local machine and copy its complete line.

cat ~/.ssh/github_actions_example.pub

Server administrator session. Prepare the SSH directory and paste the public key into authorized_keys on its own line. Preserve existing entries.

sudo install -d -m 700 -o deploy_example -g deploy_example \
  /home/deploy_example/.ssh
sudo nano /home/deploy_example/.ssh/authorized_keys
sudo chown deploy_example:deploy_example \
  /home/deploy_example/.ssh/authorized_keys
sudo chmod 600 /home/deploy_example/.ssh/authorized_keys

Inspect the server public host key and fingerprint through a trusted administrator session.

sudo cat /etc/ssh/ssh_host_ed25519_key.pub
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

Then test login from your local machine. Compare the fingerprint with the trusted output before accepting the first connection.

ssh -i ~/.ssh/github_actions_example -o IdentitiesOnly=yes \
  deploy_example@example.com 'whoami'

Expected output is deploy_example.

5. Add GitHub Repository Secrets

Open Settings → Secrets and variables → Actions → New repository secret in your GitHub repository.

Secret Value
DEPLOY_SSH_KEY Full private key, including BEGIN and END lines
DEPLOY_HOST example.com
DEPLOY_USER deploy_example
DEPLOY_PATH /var/www/example.com/public
DEPLOY_KNOWN_HOSTS Verified server public host-key entry

For SSH port 22, the known-hosts entry uses the same hostname as DEPLOY_HOST followed by the trusted host key type and key data.

example.com ssh-ed25519 ACTUAL_SERVER_PUBLIC_HOST_KEY

Replace the placeholder with the actual server public host key. The deployment key authenticates the runner. The server host key verifies the destination identity.

The sample workflow reads secrets.NAME. If you store non-sensitive settings in Repository Variables, use vars.NAME for those entries. A mismatch between these locations produces empty values.

6. Supply Production Build Configuration

The GitHub runner is a separate machine and needs the configuration required to build your application.

Use a committed environment file containing public settings, or provide build variables through the workflow according to your framework. Static frontend values become part of browser-delivered files, even when their source was stored as a secret.

Keep actual API credentials and database passwords in the backend or an appropriate secret store. For static output, changing a server environment file after the build does not rewrite the deployed JavaScript. Rebuild and redeploy when embedded settings change.

7. Create Your GitHub Actions CI/CD Pipeline

Create .github/workflows/deploy.yml in the local repository. This GitHub Actions CI/CD pipeline checks pull requests and deploys validated changes from the main branch.

For the first trial, omit the push trigger and add –dry-run to the rsync command. Follow Step 8 before enabling automatic live releases.

name: Validate and deploy

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: example-${{ github.ref }}
  cancel-in-progress: false

jobs:
  validate-and-deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - name: Checkout application
        uses: actions/checkout@v6
        with:
          persist-credentials: false

      - name: Set up Node
        uses: actions/setup-node@v6
        with:
          node-version: "22"
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Test
        run: npm run test:ci

      - name: Build
        run: |
          npm run build
          test -s dist/index.html

      - name: Deploy
        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
          DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
        run: |
          set -eu
          : "${SSH_KEY:?Missing deployment key}"
          : "${KNOWN_HOSTS:?Missing server host key}"
          : "${DEPLOY_HOST:?Missing deployment host}"
          : "${DEPLOY_USER:?Missing deployment user}"
          test "$DEPLOY_PATH" = "/var/www/example.com/public"

          umask 077
          mkdir -p "$HOME/.ssh"
          printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/deploy_key"
          printf '%s\n' "$KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"

          rsync -rlptz \
            --itemize-changes \
            --chmod=D755,F644 \
            -e "ssh -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes" \
            dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"

      - name: Remove temporary SSH key
        if: always()
        run: rm -f "$HOME/.ssh/deploy_key"

Failed lint, test or build steps prevent the later upload. Pull requests validate code, while the deployment condition limits uploads to main-branch pushes and manual main runs. Concurrency prevents simultaneous active runs in the same group, but does not provide a guaranteed FIFO queue.

The example uses readable action version tags. For tighter control, pin reusable actions to verified full commit SHAs and update the pins deliberately.

8. Test a Preview and Enable Live Deployment

After applying the first-trial changes from Step 7, commit the file to the default branch.

git add .github/workflows/deploy.yml
git diff --cached --check
git commit -m "Add CI and deployment preview"
git push origin main

Open Actions → Validate and deploy → Run workflow → main. Review dependency installation, lint, tests, build output and the rsync preview. A dry run cannot prove every actual write will succeed, so also check server permissions.

Once the preview and backup are verified, remove –dry-run and run a manual live deployment. Test the application. Then add the push trigger shown in the complete example, commit and push again. That push will start deployment.

The sample uploads files in place and retains remote-only files. A failed transfer can leave a partial release. Versioned release directories and a controlled current-symlink switch provide atomic release switching and a clearer code rollback path.

9. Adapt the Release Steps to Your Framework

Application Release work
Static React or Vue Build and upload generated assets, preserve required routing configuration and test nested routes.
Nuxt static Generate output with the required public API configuration and upload .output/public/.
Node or Nuxt server-rendered Deploy server output and required dependencies, manage its service and verify service health.
Laravel Install production Composer dependencies, preserve .env and storage, apply reviewed migrations, rebuild caches and coordinate workers.

For backend releases, take a database backup before reviewed schema changes. Coordinate maintenance mode and queue workers so requests and jobs do not use partially updated code. Give the deployment account only the specific service-management commands it needs.

After the code and dependencies are ready, rebuild required caches, resume workers and bring the application online. Restoring an old code directory does not reverse database migrations, so database recovery needs its own procedure.

10. Verify the Release and Troubleshoot

After each release, review your GitHub Actions CI/CD pipeline logs and test the application’s main user flows. Verify the homepage, direct nested-route visits, login, API requests and an application-specific operation. Add an HTTP health check as the pipeline matures.

Failure What to inspect
Empty configuration value Variable name, scope and secrets versus vars reference
SSH authentication failure Username, key pair and authorized_keys permissions
Host verification failure Verified host key, connection hostname and port
Upload permission denied Ownership and write access in every destination subdirectory
Build fails only in CI Runtime version, lockfile, environment and warning policy
Nested page returns 404 Apache or Nginx application-route fallback
Worker runs old code Worker release sequence, cache permissions and process status

When a GitHub Actions CI/CD pipeline fails, read the failed step before retrying. A server permission fix usually needs no new commit. A YAML change needs a commit and a run using the updated workflow.

Keep validation strict where possible. Document temporary exceptions that allow existing warnings. Production environments can add approvals, branch restrictions and environment-scoped secrets, subject to repository and plan availability. Branch protection can require CI checks before merging.

Official Documentation