Step-by-step
-
1
Create the workflow directory and file
GitHub Actions looks for workflow files in
.github/workflows/. Any.ymlfile there is picked up automatically. One file = one workflow.bashmkdir -p .github/workflows touch .github/workflows/deploy.yml -
2
Write the trigger and test job
Trigger on
pushtomain. Thetestjob checks out the code, installs dependencies, and runs the test suite. This example uses PHP/Laravel — swap the setup action and install command for Node, Python, etc.yamlname: CI/CD on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest services: mysql: image: mysql:8.4 env: MYSQL_ROOT_PASSWORD: secret MYSQL_DATABASE: laravel_test ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v4 - name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: "8.3" extensions: mbstring, pdo_mysql, zip, bcmath coverage: none - name: Cache Composer packages uses: actions/cache@v4 with: path: vendor key: ${{ runner.os }}-php-${{ hashFiles("**/composer.lock") }} restore-keys: ${{ runner.os }}-php- - name: Install dependencies run: composer install --no-dev --optimize-autoloader --no-interaction - name: Copy .env run: cp .env.example .env && php artisan key:generate - name: Run tests env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 DB_PORT: 3306 DB_DATABASE: laravel_test DB_USERNAME: root DB_PASSWORD: secret run: php artisan test --parallel -
3
Add the deploy job with SSH
The
deployjob runs only iftestpasses (needs: test) and only on pushes tomain(not on PRs). It SSH-es into your server and runs the deploy commands.yamldeploy: runs-on: ubuntu-latest needs: test if: github.ref == "refs/heads/main" && github.event_name == "push" steps: - name: Deploy to server uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} port: 22 script: | cd /var/www/myapp git pull origin main composer install --no-dev --optimize-autoloader php artisan migrate --force php artisan optimize php artisan view:cache chown -R www-data:www-data /var/www/myapp -
4
Store secrets in GitHub
Never put credentials in the YAML file. Store them as repository secrets and reference them with
${{ secrets.NAME }}. Navigate to your repo → Settings → Secrets and variables → Actions → New repository secret.bash# Secrets to create in GitHub: # SSH_HOST — your server IP or hostname # SSH_USER — e.g. root or deploy # SSH_KEY — the private key (the full content of ~/.ssh/id_ed25519) # Generate a dedicated deploy key (don't reuse your personal key) ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N "" # Add the public key to the server ssh-copy-id -i ~/.ssh/deploy_key.pub user@your-server # Paste the PRIVATE key content into the SSH_KEY secret on GitHub cat ~/.ssh/deploy_key -
5
Use environment-scoped secrets for staging vs production
For multi-environment setups, create Environments in GitHub (Settings → Environments). Environments can have their own secrets, protection rules (required reviewers), and deployment branch restrictions.
yamldeploy: runs-on: ubuntu-latest needs: test environment: production # Links to a GitHub Environment if: github.ref == "refs/heads/main" && github.event_name == "push" steps: - name: Deploy to production uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} # Scoped to "production" env username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} script: cd /var/www/myapp && git pull && php artisan optimize -
6
Pass build artifacts between jobs
If your build step produces a compiled artifact (Vite assets, a binary, a Docker image digest), upload it in the
testjob and download it indeploy— don't rebuild in the deploy job.yaml# In the test job — upload compiled assets - name: Build frontend run: npm ci && npm run build - uses: actions/upload-artifact@v4 with: name: frontend-build path: public/build/ deploy: needs: test runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 with: name: frontend-build path: public/build/ # Then rsync or scp the public/build/ dir to the server -
7
Add a matrix build for multiple versions
Matrix builds run the same job across multiple versions in parallel — useful for libraries or multi-version support guarantees. Each combination is a separate job in the Actions UI.
yamltest: runs-on: ubuntu-latest strategy: matrix: php: ["8.2", "8.3"] # node: [18, 20, 22] # Swap for Node projects steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - run: composer install --no-interaction - run: php artisan test -
8
Troubleshoot failed runs
Most failures come from one of three places. Check them in order:
- Missing secret — a blank secret causes a cryptic SSH error. Re-check the secret name matches exactly (case-sensitive).
- SSH key format — the key must include the
-----BEGIN...-----and-----END...-----lines. Paste it raw, no extra spaces. - Server permission error — the deploy user must own the app directory or be in the
www-datagroup. Runchown -R deploy:deploy /var/www/myapponce.
bash# Re-run a failed workflow from the CLI gh run list --limit 5 gh run rerun <run-id> # Watch a run live gh run watch <run-id> # Download logs from a failed run gh run view <run-id> --log-failed
Tips & gotchas
- Use <code>concurrency</code> groups to cancel in-progress runs when a new push arrives — prevents multiple deploys queuing up from rapid commits: <code>concurrency: { group: deploy, cancel-in-progress: true }</code>
- Add a <code>workflow_dispatch</code> trigger so you can manually kick off a deploy from the GitHub UI without a push: <code>on: [push, workflow_dispatch]</code>
- Cache your package manager dependencies with <code>actions/cache@v4</code> keyed on the lockfile hash. A warm cache cuts install time from 60 s to under 5 s.
- Keep the deploy job fast — run <code>php artisan migrate --pretend</code> in the test job to catch migration errors before you SSH to the server.
- Use <code>actions/checkout@v4</code> with <code>fetch-depth: 0</code> if your tests or versioning tools need the full git history.
Wrapping up
You now have a pipeline that runs tests on every push, blocks deploys when tests fail, and ships to production over SSH when the branch is main and tests are green. Extend it incrementally — add code quality checks (phpstan, eslint), notifications (Slack, email), or a staging environment gate before production.