How to set up an automatic deploy pipeline with GitHub Actions
Deploy & DevOps · By Kevin Marcondes · Published on · Updated on · 8 min read
This tutorial walks you step by step through creating an automated deploy pipeline with GitHub Actions. You will create a workflow YAML that runs tests, builds a Docker image, publishes it to GHCR, and deploys via SSH using Docker Compose. The example is generic and can be adapted to different stacks.
Prerequisites
You need a GitHub repository and permission to create workflows. On the target server, have Docker and Docker Compose installed, SSH access configured, and firewall ports opened. Create a healthcheck endpoint in your application. Configure a domain and point DNS to the server if you plan to expose it on the web.
If your project doesn't yet have automated tests, start simple. If you use legacy PHP, see guidance on legacy PHP code testing.
- GitHub repository with a main branch (e.g., main)
- Linux server with Docker and Docker Compose
- SSH access with a private key
- A /health endpoint or similar
- Configured domain (optional)
1) Create the basic workflow
We'll start with a minimal workflow that runs on push and pull request to the main branch. It checks out the code and prepares the pipeline environment. From here we'll extend it to run tests, build, and deploy.
# .github/workflows/deploy.yml
name: CI/CD
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
2) Cache dependencies and run tests
As an example we'll use Node.js. Adapt steps to your stack. Caching speeds up installs. Run tests on every push and PR to prevent regressions. If you don't have tests yet, at least run a lint or a quick build.
For PHP/Laravel projects, see guidance on GitHub Actions for PHP/Laravel.
# Adicione ao job build
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Instalar deps
run: npm ci
- name: Testes
run: npm test --if-present
3) Build the Docker image and push to GHCR
Packaging the application in Docker standardizes deployment. We'll publish to GitHub Container Registry (GHCR) using the GITHUB_TOKEN. This avoids setting up external registries.
If you're deciding between containers and VMs for production, compare pros and cons in Docker vs VMs.
- Immutable tags (SHA) make tracking and rollback easier
- Set permissions -> packages: write to use GHCR
# No mesmo arquivo, após o job build
docker_image:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Definir tag da imagem
id: meta
run: echo "tag=${GITHUB_SHA}" >> $GITHUB_OUTPUT
- name: Login no GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build e push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
${{ env.IMAGE_NAME }}:main
4) Prepare the server with Docker Compose
On the server we'll use Docker Compose to orchestrate the application. The file below references the GHCR image and uses the REPO_IMAGE and IMAGE_TAG variables, which we'll send during deploy.
Attention: running Compose with the new image will restart the service. Use a maintenance window or blue/green techniques to avoid downtime.
# docker-compose.yml (no servidor)
version: '3.8'
services:
app:
image: ghcr.io/${REPO_IMAGE}:${IMAGE_TAG}
env_file:
- .env
ports:
- "80:3000"
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 3s
retries: 5
# Mantenha um arquivo .env com as variáveis de runtime da sua app.
5) Deploy via SSH using Docker Compose
The deploy job connects over SSH, sends the version variables, and runs Docker Compose on the server. It also logs in to GHCR to pull the private image.
Store keys and tokens as repository secrets. Never commit secrets to Git.
- Required secrets: SSH_HOST, SSH_USER, SSH_KEY, GHCR_USER, GHCR_TOKEN (read:packages)
deploy:
needs: docker_image
runs-on: ubuntu-latest
environment:
name: production
steps:
- uses: actions/checkout@v4
- name: Preparar chave SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Enviar vars de versão
env:
IMAGE_TAG: ${{ needs.docker_image.outputs.image_tag }}
run: |
printf "REPO_IMAGE=%s\nIMAGE_TAG=%s\n" "${{ github.repository }}" "$IMAGE_TAG" > .env.deploy
scp -i ~/.ssh/id_rsa .env.deploy ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/opt/app/.env
- name: Deploy remoto
env:
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
ssh -i ~/.ssh/id_rsa ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
"echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USER --password-stdin && \
cd /opt/app && docker compose pull && docker compose up -d && docker image prune -f"
6) Secrets, environments, and approvals
Use GitHub Environments to isolate secrets per environment and require human approval before deploying to production. Define secrets in the matching environment and set required reviewers. In YAML, just reference the environment name. Approval is handled outside the YAML in the repository settings.
- Create environments: staging and production
- Store secrets per environment
- Require approval for production
- Adapt notifications (Slack, email) as needed
deploy:
needs: docker_image
runs-on: ubuntu-latest
environment:
name: production
url: https://sua-app.com
steps:
- uses: actions/checkout@v4
# ... (restante igual ao passo anterior)
7) How to verify it worked
Validate the healthcheck and check running containers. Confirm the version via exposed tag/commit if your app shows it. Automate a simple check at the end of the deploy to fail fast on errors.
- HTTP 200 healthcheck
- Container shows healthy in Docker
- Logs without critical errors
# Do runner ou da sua máquina
curl -fsS https://sua-app.com/health && echo "OK"
# No servidor remoto
ssh $USER@$HOST "docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' && docker compose ps && docker compose logs --tail=50"
8) Common issues and fixes
Here are frequent errors and how to resolve them. Avoid making manual changes on the server that aren't described as code — that harms reproducibility and rollback. For database migrations, choose safe practices to prevent downtime.
- Host key verification failed: run ssh-keyscan to populate known_hosts.
- Permission denied on GHCR: verify GHCR_USER and GHCR_TOKEN with scope read:packages.
- docker compose: command missing? Install the Compose plugin/CLI on the server.
- Healthcheck timeout: check ports and firewall; validate the /health route.
- Rollback: run docker compose up -d with the previous tag (previous SHA).
- Migrations without downtime: see database versioning best practices and zero-downtime with CDC.
9) Next steps and improvements
You can add a version matrix, static analysis, and vulnerability scanning. For larger pipelines, audit your process and address bottlenecks, long queues, and single points of failure. When deploys become business-critical, invest in observability and regular pipeline reviews.
- Version matrix (Node, Python, PHP)
- Parallel builds per service (monorepo)
- SCA/Container scanning
- Blue/green or canary deployments
- Observability and alerts
- Audit deploy processes
Frequently asked questions
Do I have to use Docker to deploy with GitHub Actions?
No. You can rsync artifacts (static build) or publish packages and install them on the server. Docker standardizes the runtime and simplifies rollback, but it's not mandatory. Adapt the deploy job to your current process (systemd, PM2, etc.).
How do I perform a quick rollback if a release breaks?
Use immutable tags with the SHA. On the server, run Docker Compose pointing to the previous tag and apply “docker compose up -d”. Keep a short history of local images to speed rollback and monitor the healthcheck after switching.
How do I protect secrets in the pipeline?
Store keys and tokens in repository Secrets or in the Environment, never in code. Restrict permissions, rotate keys, and use environments with approval for production. Limit logs to avoid exposing sensitive values during the pipeline.
Can I have multiple environments (staging and production)?
Yes. Create two environments and either two deploy jobs or a single deploy job parameterized by a manual input (workflow_dispatch). Store secrets per environment and allow production deploys only after approval and staging verification.
What about database migrations during deploy?
Plan migrations for backward compatibility. Apply changes that don't break the current version first. Run idempotent migration steps in the pipeline, monitor impact, and when needed adopt zero-downtime strategies such as CDC.
Want to go beyond the basics of the pipeline?
If your deploy requires blue/green, canary releases, sensitive migrations, or observability, evolve the architecture and process with tailored technical reviews and automations.
Discuss CI/CD improvementsSee also
- Tutorial: Implement Canary releases with Traefik in Docker Compose using headers and weighted routing for gradual PHP deployments
- Tutorial: Configure HAProxy with Let's Encrypt and health checks to load balance traffic between Docker containers on a VPS (step-by-step)
- Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian