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
- Push or merge reviewed changes into
main. - GitHub Actions starts the deployment workflow.
- Approve the deployment if required reviewers are configured and supported.
- The runner connects to production using its SSH private key.
- Production fetches the repository using its separate repository credentials.
- 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
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
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"
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
Save the verified host-key lines, not the fingerprint display, as SSH_KNOWN_HOSTS.
5. Open the repository settings in GitHub
- Sign in to GitHub with repository administration access.
- Open YOUR_ORG/YOUR_REPOSITORY.
- Click Settings in the repository navigation. If hidden, check the overflow menu.
- In the left sidebar, under Code and automation, click Environments.
- If
productionalready exists, click it. Your screenshot shows it already exists; edit that environment instead of creating another one. - 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
- In
production, find Deployment branches and tags. - Select Selected branches and tags.
- Click Add deployment branch or tag rule.
- Select Branch, enter
main, and save the rule.
5.4 Configure required reviewers when available
- Under Deployment protection rules, enable Required reviewers.
- Add yourself or the reviewer responsible for production.
- Save the protection rules.
- 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"
In GitHub:
- Open
YOUR_ORG/YOUR_REPOSITORY. - Select Settings → Deploy keys → Add deploy key.
- Enter a title such as
Production hl-home read-only. - Paste
hl_home_repo.pub. - Leave Allow write access unticked.
- 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
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
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
- Open the repository's Code tab.
- Inspect
.github/workflows/first. If a production deployment workflow already exists, review and update it to avoid duplicate deployments. - Click Add file → Create new file.
- Enter
.github/workflows/deploy-production.ymlas the filename. - Paste the YAML below.
- Click Commit changes.
- Prefer a new branch and pull request; review it and merge into
main. - Be aware that merging this file into
mainstarts 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"
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 buildexists. - 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:cachecan affect code that readsenv()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"
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
- Open the repository's Actions tab.
- Select Deploy hl-home to production.
- Click Run workflow.
- Select branch
main. - 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
- Open the waiting workflow run.
- Click Review deployments.
- Select
production. - Click Approve and deploy.
- 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
Example output format only:
abc1234 Update homepage content
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
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'
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:
- Managing deployment environments
- Deployment protection rules and reviewers
- Reviewing deployments
- GitHub SSH key fingerprints
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)