Debug School

rakesh kumar
rakesh kumar

Posted on

How to Deploy Using a GitHub Actions Workflow

A step-by-step guide for deploying YOUR_ORG/YOUR_REPOSITORY from GitHub to the production server using SSH and the production environment.

1. Understand the deployment flow

  1. Push or merge reviewed changes into main.
  2. GitHub Actions starts the deployment workflow.
  3. Approve the deployment if required reviewers are configured and supported.
  4. The runner connects to production using its SSH private key.
  5. Production fetches the repository using its separate repository credentials.
  6. The workflow checks the branch and working tree, advances to the triggering commit, installs PHP dependencies, refreshes Laravel configuration/views, and checks the website.

This guide documents configuration and provides a workflow template. It does not establish that a deployment has already succeeded. Review the application's build, PHP, migration, and worker requirements before using the template.

2. Production configuration

Setting Value
Repository YOUR_ORG/YOUR_REPOSITORY
Deployment branch main
GitHub environment production
SSH_HOST YOUR_SERVER_IP
SSH_PORT 22
SSH_USER deploy
DEPLOY_PATH /path/to/your/application
HEALTH_URL https://your-domain.example/
PHP_BIN /opt/lampp/bin/php
COMPOSER_BIN /usr/local/bin/composer

Connection details have been replaced with placeholders. Replace YOUR_SERVER_IP, YOUR_ORG/YOUR_REPOSITORY, /path/to/your/application, and https://your-domain.example/ with your own values before running commands. Verify the PHP and Composer paths on production. The supplied screenshots show five environment secrets and four variables, but do not prove their stored values are valid. PHP_BIN is an explicit variable required by this template; its value was not visible in the screenshot.

The example uses a dedicated deploy account. Replace it with your authorized deployment user. Composer plugins and scripts execute with that account's privileges.

3. Check production before configuring GitHub

Log in from your computer:

ssh -p 22 deploy@YOUR_SERVER_IP
Enter fullscreen mode Exit fullscreen mode

Run on production:

APP=/path/to/your/application
cd "$APP"
git status --short
git branch --show-current
git remote -v
git fetch origin main
git log --oneline -1
/opt/lampp/bin/php -v
command -v composer
/opt/lampp/bin/php /usr/local/bin/composer --version
Enter fullscreen mode Exit fullscreen mode

Expected: branch main, no tracked or untracked deployment changes, working repository access, and PHP/Composer compatible with composer.json and composer.lock. Do not discard server changes to make the checks pass: review and commit, move, or back them up first. Keep .env and uploaded files outside Git tracking and appropriately ignored.

Do not assume PHP 8.4 is required without checking this repository's requirements. The workflow uses the configured PHP explicitly and checks Composer's actual platform requirements.

4. Create the GitHub Actions login key on production

Run this in Bash on the production server as the account GitHub will use:

set -e
APP=/path/to/your/application
KEY="$HOME/.ssh/gha_deploy"

mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
if [ ! -f "$KEY" ]; then
  ssh-keygen -t ed25519 -N "" -C "github-actions-deploy" -f "$KEY"
fi
touch "$HOME/.ssh/authorized_keys"
grep -qxF "$(cat "$KEY.pub")" "$HOME/.ssh/authorized_keys" || \
  cat "$KEY.pub" >> "$HOME/.ssh/authorized_keys"
chmod 600 "$HOME/.ssh/authorized_keys"

printf '\nSSH_HOST = YOUR_SERVER_IP\nSSH_PORT = 22\n'
printf 'SSH_USER = %s\nDEPLOY_PATH = %s\n' "$(whoami)" "$APP"
printf 'HEALTH_URL = https://your-domain.example/\n'
printf '\nServer host-key fingerprints:\n'
for hostkey in /etc/ssh/ssh_host_*_key.pub; do
  ssh-keygen -lf "$hostkey"
done
printf '\nCandidate SSH_KNOWN_HOSTS lines:\n'
ssh-keyscan -p 22 YOUR_SERVER_IP 2>/dev/null
printf '\nSSH_PRIVATE_KEY: copy the entire block into GitHub only:\n'
cat "$KEY"
Enter fullscreen mode Exit fullscreen mode

The private-key output is sensitive. Copy the entire block, including BEGIN and END, into the GitHub secret. Do not paste it into source code, workflow YAML, documentation, or chat.

ssh-keyscan collects host keys but does not authenticate them. Compare its fingerprints with /etc/ssh/ssh_host_*_key.pub through your trusted server session or provider console before saving SSH_KNOWN_HOSTS. From your computer, you can inspect the candidate fingerprints with:

ssh-keyscan -p 22 YOUR_SERVER_IP 2>/dev/null > /tmp/hl-hostkeys
ssh-keygen -lf /tmp/hl-hostkeys
Enter fullscreen mode Exit fullscreen mode

Save the verified host-key lines, not the fingerprint display, as SSH_KNOWN_HOSTS.

5. Open the repository settings in GitHub

  1. Sign in to GitHub with repository administration access.
  2. Open YOUR_ORG/YOUR_REPOSITORY.
  3. Click Settings in the repository navigation. If hidden, check the overflow menu.
  4. In the left sidebar, under Code and automation, click Environments.
  5. If production already exists, click it. Your screenshot shows it already exists; edit that environment instead of creating another one.
  6. Otherwise, click New environment, enter production, and click Configure environment.

Direct environment settings: https://github.com/YOUR_ORG/YOUR_REPOSITORY/settings/environments

5.1 Add the five environment secrets

Inside Settings → Environments → production, scroll to Environment secrets.

For each entry, click Add environment secret, enter the exact name and its value, then click Add secret. Use the pencil/edit control to update an existing secret.

Secret name Value to enter
SSH_HOST YOUR_SERVER_IP
SSH_PORT 22
SSH_USER deploy
SSH_KNOWN_HOSTS All verified host-key lines from the previous step
SSH_PRIVATE_KEY Entire private key block, including BEGIN/END

GitHub does not display existing secret values after saving. A name appearing in the list confirms the secret exists, not that its value is correct.

5.2 Add the four environment variables

On the same environment page, find Environment variables. Click Add environment variable, enter the name and value, and save. Repeat for all four:

Variable name Value
DEPLOY_PATH /path/to/your/application
HEALTH_URL https://your-domain.example/
PHP_BIN /opt/lampp/bin/php
COMPOSER_BIN /usr/local/bin/composer

Use plain values without surrounding quotation marks. These are environment variables under production, not repository secrets. The workflow must declare environment: production to access them.

5.3 Restrict deployment to main

  1. In production, find Deployment branches and tags.
  2. Select Selected branches and tags.
  3. Click Add deployment branch or tag rule.
  4. Select Branch, enter main, and save the rule.

5.4 Configure required reviewers when available

  1. Under Deployment protection rules, enable Required reviewers.
  2. Add yourself or the reviewer responsible for production.
  3. Save the protection rules.
  4. If you are the only reviewer and also start deployments, leave Prevent self-review disabled; enabling it requires another eligible reviewer to approve your runs.

Availability depends on repository visibility and GitHub plan. GitHub Free/Pro/Team support required reviewers for public repositories; private/internal repositories need an eligible Enterprise plan for this protection. Private environments and environment secrets also have plan requirements. If the reviewer option is unavailable, this workflow's push trigger will deploy without that approval gate. Remove the push trigger and use manual runs if that matches your release process.

6. Ensure production can pull the private repository

There are two separate SSH connections:

Connection Credential
GitHub runner → production SSH_PRIVATE_KEY, matching production authorized_keys
Production → GitHub repository A separate read-only repository deploy key or existing approved Git credentials

If git fetch origin main already works as the deployment user, keep the working configuration.

If it fails and you use an SSH remote, generate a separate key on production:

ssh-keygen -t ed25519 -N "" -C "hl-home-repository-readonly" \
  -f "$HOME/.ssh/hl_home_repo"
cat "$HOME/.ssh/hl_home_repo.pub"
Enter fullscreen mode Exit fullscreen mode

In GitHub:

  1. Open YOUR_ORG/YOUR_REPOSITORY.
  2. Select Settings → Deploy keys → Add deploy key.
  3. Enter a title such as Production hl-home read-only.
  4. Paste hl_home_repo.pub.
  5. Leave Allow write access unticked.
  6. Click Add key.

On production, merge this host entry into ~/.ssh/config without overwriting existing entries:

Host github-hl-home
  HostName github.com
  User git
  IdentityFile ~/.ssh/hl_home_repo
  IdentitiesOnly yes
Enter fullscreen mode Exit fullscreen mode
chmod 600 "$HOME/.ssh/config" "$HOME/.ssh/hl_home_repo"
git -C /path/to/your/application remote set-url origin \
  git@github-hl-home:YOUR_ORG/YOUR_REPOSITORY.git
git -C /path/to/your/application fetch origin main
Enter fullscreen mode Exit fullscreen mode

Verify GitHub's host key against its published fingerprints before accepting a first connection. Keep this repository private key on production: the server needs it for future fetches.

7. Create the workflow in GitHub

  1. Open the repository's Code tab.
  2. Inspect .github/workflows/ first. If a production deployment workflow already exists, review and update it to avoid duplicate deployments.
  3. Click Add file → Create new file.
  4. Enter .github/workflows/deploy-production.yml as the filename.
  5. Paste the YAML below.
  6. Click Commit changes.
  7. Prefer a new branch and pull request; review it and merge into main.
  8. Be aware that merging this file into main starts its first deployment because it has a push trigger.

Complete workflow template

This example uses standard SSH and does not depend on a third-party SSH action. It assumes a Laravel application with a committed composer.lock, an existing production .env, writable Laravel runtime directories, and no frontend build needed during this deployment. Adapt it if the repository differs.

name: Deploy hl-home to production

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: hl-home-production
  cancel-in-progress: false

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    environment: production
    env:
      SSH_HOST: ${{ secrets.SSH_HOST }}
      SSH_PORT: ${{ secrets.SSH_PORT }}
      SSH_USER: ${{ secrets.SSH_USER }}
      SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
      SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
      DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
      PHP_BIN: ${{ vars.PHP_BIN }}
      COMPOSER_BIN: ${{ vars.COMPOSER_BIN }}
      HEALTH_URL: ${{ vars.HEALTH_URL }}
      TARGET_SHA: ${{ github.sha }}
    steps:
      - name: Configure SSH
        shell: bash
        run: |
          set -euo pipefail
          for name in SSH_HOST SSH_PORT SSH_USER SSH_KNOWN_HOSTS SSH_PRIVATE_KEY DEPLOY_PATH PHP_BIN COMPOSER_BIN HEALTH_URL; do
            if [[ -z "${!name}" ]]; then
              echo "Missing configuration: $name" >&2
              exit 1
            fi
          done
          [[ "$SSH_PORT" =~ ^[0-9]+$ ]]
          [[ "$SSH_HOST" =~ ^[a-zA-Z0-9.-]+$ ]]
          [[ "$SSH_USER" =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]]
          install -m 700 -d "$HOME/.ssh"
          umask 077
          printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/gha_deploy"
          printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"

      - name: Deploy the triggering commit
        shell: bash
        run: |
          set -euo pipefail
          printf -v remote_command 'bash -s -- %q %q %q %q' \
            "$DEPLOY_PATH" "$PHP_BIN" "$COMPOSER_BIN" "$TARGET_SHA"
          ssh -i "$HOME/.ssh/gha_deploy" -p "$SSH_PORT" \
            -o BatchMode=yes -o StrictHostKeyChecking=yes \
            -o ConnectTimeout=20 \
            "$SSH_USER@$SSH_HOST" "$remote_command" <<'REMOTE'
          set -euo pipefail
          APP="$1"
          PHP_BIN="$2"
          COMPOSER_BIN="$3"
          TARGET_SHA="$4"
          cd "$APP"
          exec 9>"$HOME/.hl-home-deploy.lock"
          flock -n 9 || { echo 'Another deployment is running'; exit 1; }
          test "$(git branch --show-current)" = main
          test -z "$(git status --porcelain)" || {
            echo 'Working tree is not clean; review server changes first'; exit 1;
          }
          test -f .env
          test -x "$PHP_BIN"
          test -f "$COMPOSER_BIN"
          "$PHP_BIN" -v
          git fetch origin main
          git cat-file -e "$TARGET_SHA^{commit}"
          git merge-base --is-ancestor "$TARGET_SHA" origin/main
          git merge-base --is-ancestor HEAD "$TARGET_SHA" || {
            echo 'Target would rewind or diverge from production; stopping'; exit 1;
          }
          echo "Previous commit: $(git rev-parse HEAD)"
          git merge --ff-only "$TARGET_SHA"
          test -f composer.lock
          "$PHP_BIN" "$COMPOSER_BIN" check-platform-reqs --lock --no-dev
          "$PHP_BIN" "$COMPOSER_BIN" install \
            --no-dev --prefer-dist --no-interaction --optimize-autoloader
          "$PHP_BIN" "$COMPOSER_BIN" check-platform-reqs --no-dev
          "$PHP_BIN" artisan config:cache
          "$PHP_BIN" artisan view:cache
          echo "Deployed commit: $(git rev-parse HEAD)"
          REMOTE

      - name: Check public website
        shell: bash
        run: |
          set -euo pipefail
          curl --fail --silent --show-error --location \
            --connect-timeout 10 --max-time 30 \
            --retry 3 --retry-delay 5 --retry-all-errors \
            --output /dev/null "$HEALTH_URL"

      - name: Remove runner private key
        if: always()
        shell: bash
        run: rm -f "$HOME/.ssh/gha_deploy"
Enter fullscreen mode Exit fullscreen mode

The workflow deploys the exact triggering commit instead of silently deploying a later main commit. It stops if production contains uncommitted changes or if the requested commit would rewind/diverge from the current server commit. An old queued run can therefore fail after a newer commit has already been deployed.

Template limitations to resolve for your application

  • This updates the live directory in place; it is not atomic and does not guarantee zero downtime. Failure after advancing Git can leave code, dependencies, or caches partially updated. Composer platform checks happen after advancing Git in this simple template. Use separate release directories and an atomic symlink switch for stronger recovery guarantees.
  • Database migrations are intentionally not automatic. Review migrations and take a database backup before running a required migration: /opt/lampp/bin/php artisan migrate --force. Code requiring a schema change needs a coordinated release.
  • If assets need compilation, add a build appropriate to the repository's lockfile and Node version. Do not assume npm run build exists.
  • If the app uses queue workers, reload them through its established process manager or the appropriate Laravel queue/Horizon command after deployment.
  • Validate OPcache behavior. If timestamp checks are disabled, use your established PHP/Apache reload process; a successful page request alone may still serve old cached code.
  • A homepage HTTP check confirms reachability, not that the intended commit served the response. Use an application health/version endpoint for stronger verification.
  • Review Laravel cache behavior for this app; config:cache can affect code that reads env() outside configuration files. Route caching is omitted because compatibility has not been verified.

8. Remove the temporary Actions private key from production

After saving the GitHub secret and confirming the first workflow can authenticate, remove only the Actions login private key from production:

rm -- "$HOME/.ssh/gha_deploy"
Enter fullscreen mode Exit fullscreen mode

Keep gha_deploy.pub and its entry in authorized_keys. Keep hl_home_repo if production uses it to fetch GitHub. Deleting gha_deploy does not revoke access; removing the matching public-key entry from authorized_keys does.

9. Run the workflow and approve the deployment

Automatic run

Push or merge a change into main, then open Actions → Deploy hl-home to production → latest run.

Manual run

  1. Open the repository's Actions tab.
  2. Select Deploy hl-home to production.
  3. Click Run workflow.
  4. Select branch main.
  5. Click Run workflow to submit.

The manual button appears after a workflow with workflow_dispatch exists on the default branch. This template rejects deployment runs from branches other than main.

Approval when configured

  1. Open the waiting workflow run.
  2. Click Review deployments.
  3. Select production.
  4. Click Approve and deploy.
  5. Follow the deployment and health-check logs.

If no approval rule is configured, the job starts automatically. A green run means the configured commands completed, including the HTTP check; still verify key user flows.

10. Verify the deployed commit on production

Run the exact command you requested:

git -C /path/to/your/application log --oneline -1
Enter fullscreen mode Exit fullscreen mode

Example output format only:

abc1234 Update homepage content
Enter fullscreen mode Exit fullscreen mode

Compare this with the commit shown in the GitHub Actions run. For the full commit ID:

git -C /path/to/your/application rev-parse HEAD
Enter fullscreen mode Exit fullscreen mode

Also open https://your-domain.example/ and test the changed page plus essential login/navigation flows. The commit command verifies the checkout on disk, not frontend caches or OPcache.

11. Optional: configure secrets using GitHub CLI

Run on a trusted computer with gh installed and authenticated to the correct repository. Create/configure the environment in the UI first.

gh secret set SSH_HOST --env production --repo YOUR_ORG/YOUR_REPOSITORY
gh secret set SSH_PORT --env production --repo YOUR_ORG/YOUR_REPOSITORY
gh secret set SSH_USER --env production --repo YOUR_ORG/YOUR_REPOSITORY
gh secret set SSH_KNOWN_HOSTS --env production --repo YOUR_ORG/YOUR_REPOSITORY
gh secret set SSH_PRIVATE_KEY --env production --repo YOUR_ORG/YOUR_REPOSITORY

gh variable set DEPLOY_PATH --env production --repo YOUR_ORG/YOUR_REPOSITORY \
  --body '/path/to/your/application'
gh variable set HEALTH_URL --env production --repo YOUR_ORG/YOUR_REPOSITORY \
  --body 'https://your-domain.example/'
gh variable set PHP_BIN --env production --repo YOUR_ORG/YOUR_REPOSITORY \
  --body '/opt/lampp/bin/php'
gh variable set COMPOSER_BIN --env production --repo YOUR_ORG/YOUR_REPOSITORY \
  --body '/usr/local/bin/composer'
Enter fullscreen mode Exit fullscreen mode

Each gh secret set prompts for the secret. For a multiline key available as a local file, redirect that file into gh secret set SSH_PRIVATE_KEY ... rather than putting its contents into command-line arguments.

12. Troubleshooting

Symptom What to check
Permission denied (publickey) connecting to production Correct SSH user; private key matches authorized_keys; key contains real newlines; .ssh/file permissions
Host key verification failed Verified SSH_KNOWN_HOSTS matches host and port; for a nonstandard port use [host]:port host-key entries
SSH timeout Public address, SSH port, security group/firewall, and runner network access; hosted runner addresses can vary
git fetch fails Production-to-GitHub credentials, remote URL, GitHub host trust, deploy-key repository access
Missing environment configuration Exact names and environment: production; variables belong in Environment variables
PHP or extension mismatch Configured PHP binary and composer.lock platform requirements; upgrade PHP/extensions rather than ignoring requirements
Composer fails running as root Review plugin/script trust and use a dedicated deployment account; do not disable safety checks blindly
Dirty working tree Review server-side changes; do not automatically run destructive reset/clean commands
Target would rewind or diverge Current production commit is ahead or on a different history; inspect commits before choosing recovery
Job waits for approval Open Review deployments; verify reviewer permissions and self-review settings
Required reviewers option absent Repository visibility and GitHub plan may not support it
Workflow green but page old CDN/browser caches, OPcache, asset build, vhost webroot, and version endpoint
Health check fails Application logs, Apache/PHP errors, .env, DB connectivity, public URL, TLS/DNS

13. Recovery after a failed deployment

Record the previous commit from the deployment log. Inspect the failed step and the server's current checkout before making another change. Fix the cause and deploy a reviewed correction. Do not blindly rerun after partial dependency/cache updates or database changes. A rollback requires coordinating code, dependencies, assets, caches, and any schema changes; this template has no automatic rollback.

14. Official references

Documentation checked on 4 November 2026:

The workflow is a reviewable template; it has not been run against your repository or production server as part of writing this guide.

Top comments (0)