Back to blog Brazil PaaS

CI/CD with GitHub Actions for container deployment in Brazil

Build an automated deploy pipeline with GitHub Actions that builds your Docker image and publishes to Guara Cloud. No extra tools, no unnecessary complexity.

7 min read

By Guara Cloud Editorial

Tested with GitHub Actions / Docker / Node.js 20 / Guara Cloud

Manual deploys work fine while the team is small. By week three, someone forgets to run the build before pushing the image. By week six, a late-night hotfix goes to the wrong branch. Setting up CI/CD is not a luxury. It is something you should have done on day one.

This post covers a GitHub Actions workflow that builds your Docker image and deploys it to Guara Cloud on every push to the main branch. No paid add-ons, no 400-line YAML files.

Quick answer

You need a .github/workflows/deploy.yml file with four stages: code checkout, Docker image build on GitHub Actions runners, image push to the Guara Cloud registry, and deploy via API. Secrets (API token and registry URL) go into repository settings. The pipeline runs on every push to main, and on pull requests it only runs the build to confirm the image compiles.

Key takeaways

  • GitHub Actions provides 2,000 free minutes per month on private repositories. Enough for small teams.
  • Build the image on the Actions runner, not on the production server. Guara Cloud receives the finished image.
  • Use docker/build-push-action with GitHub cache so subsequent builds finish in seconds.
  • Keep workflows separate: one to validate (PR) and another to deploy (merge to main).
  • Never put API tokens or registry passwords as plain text in YAML. Always use ${{ secrets.NAME }}.

When this flow applies

This setup works well for web applications (Node.js, Next.js, NestJS, Python, Go) that run in containers and use GitHub as the source. If the team has 1 to 10 developers and ships daily or weekly, Actions automation covers the entire use case. It works for both staging and production if you use different branches.

When not to use this flow

If the repository is very large (2GB+ monorepo), checkout time on Actions starts to hurt. Sparse checkout or local CI might be better in that case. If the company requires self-hosted runners for compliance, the YAML barely changes but the infrastructure changes significantly. For projects that do not use containers (static file deploys, for example), the flow is different and simpler.

Before you start

  • GitHub repository with your application code
  • Working Dockerfile at the project root
  • Active Guara Cloud account with a service already created
  • Guara Cloud API token (generated in the settings panel)
  • Admin permission on the repository to add secrets

1. Create the API token and configure secrets

In Guara Cloud, go to Settings > API Tokens and generate a token with deploy scope. Copy the token. In GitHub, open the repository, go to Settings > Secrets and variables > Actions, and add:

Required secrets in GitHub

Secret Description
GUARA_API_TOKEN API token generated in the Guara Cloud panel
GUARA_REGISTRY_URL Container registry URL (e.g. registry.guaracloud.com)
GUARA_SERVICE_ID ID of the service to deploy to

Do not share these values in issues, PRs, or workflow logs. GitHub automatically masks secret values in logs, but being careful never hurts.

2. The validation workflow (for pull requests)

This file runs on every PR. It builds the Docker image to confirm the Dockerfile still works, but does not push or deploy anything.

.github/workflows/validate.yml
name: Validate
on:
pull_request:
  branches: [main]

jobs:
build:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

    - name: Build Docker image
      uses: docker/build-push-action@v5
      with:
        context: .
        push: false
        tags: test-build:pr-${{ github.event.pull_request.number }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

If the build fails here, the PR shows red and nobody can merge. This prevents broken images from reaching main.

3. The deploy workflow (for merge to main)

This is the file that does the actual work. It runs when something lands on main, typically through a merged and approved PR.

.github/workflows/deploy.yml
name: Deploy
on:
push:
  branches: [main]

env:
IMAGE_TAG: ${{ github.sha }}

jobs:
deploy:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

    - name: Login to Guara Cloud registry
      uses: docker/login-action@v3
      with:
        registry: ${{ secrets.GUARA_REGISTRY_URL }}
        username: api
        password: *** secrets.GUARA_API_TOKEN }}

    - name: Build and push image
      uses: docker/build-push-action@v5
      with:
        context: .
        push: true
        tags: ${{ secrets.GUARA_REGISTRY_URL }}/${{ secrets.GUARA_SERVICE_ID }}:${{ env.IMAGE_TAG }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

    - name: Deploy to Guara Cloud
      run: |
        curl -X POST https://api.guaracloud.com/v1/services/${{ secrets.GUARA_SERVICE_ID }}/deploy \
          -H "Authorization: Bearer *** secrets.GUARA_API_TOKEN }}" \
          -H "Content-Type: application/json" \
          -d '{"image_tag": "'${{ env.IMAGE_TAG }}'"}'
      shell: bash

Every deploy uses the commit SHA as the image tag. This makes rollback straightforward later, because the tag maps exactly to the commit that was deployed.

4. Cut build time with caching

Without cache, docker build reinstalls every dependency on each pipeline run. With GitHub Actions cache, unchanged layers are reused.

The difference is noticeable. On a typical Node.js project, a build without cache takes 3 to 4 minutes. With cache enabled, it drops to 40 to 60 seconds when only application code changed.

The lines responsible for this in the YAML above are:

cache-from: type=gha
cache-to: type=gha,mode=max

mode=max exports all layers, not just the final stage. In multi-stage Dockerfiles, this matters because the build stage (which runs npm install) also gets cached.

5. Deploy notifications

Watching a pipeline fail without knowing about it is bad. Add a Slack or Discord notification as the last step in the workflow:

Step added at the end of the deploy job
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
  webhook-url: ${{ secrets.SLACK_WEBHOOK }}
  payload: |
    {
      "text": "Deploy ${{ job.status }}: ${{ github.repository }}@${{ github.sha }}"
    }

The if: always() ensures the notification fires even when the deploy fails. Without it, you only find out by checking GitHub manually.

6. Pre-merge checks

The validation workflow (step 2) can be extended. Adding tests and lint to the same job ensures the PR does not merge with failures:

Extended job with test and lint
jobs:
validate:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: 'npm'
    - run: npm ci
    - run: npm run lint
    - run: npm test
    - name: Build Docker image
      uses: docker/build-push-action@v5
      with:
        context: .
        push: false
        cache-from: type=gha
        cache-to: type=gha,mode=max

Set up a branch protection rule on main to require the validate job to pass before allowing merges. This stops broken code from reaching production.

Common problems

Problem Docker login fails with 401 Unauthorized
How to fix Check that the API token has deploy scope and the GUARA_API_TOKEN secret is configured correctly. Tokens expire, so generate a fresh one if needed.
Problem Image build takes more than 10 minutes
How to fix Enable GHA cache in build-push-action. If already enabled, verify that the Dockerfile orders layers correctly: dependencies first, source code last.
Problem Deploy triggers on push to any branch
How to fix Confirm the on.push.branches trigger lists only [main]. Patterns like branches: ["*"] fire on every branch.
Problem Deploy curl returns 404
How to fix The GUARA_SERVICE_ID might be wrong. Check the Guara Cloud panel and confirm the service exists in your account.
Problem Logs show "layer not found" in cache
How to fix GHA cache has a size limit. If the project produces many layers, consider using registry cache (type=registry) instead of GHA.

Can I use this flow with monorepos (Nx, Turborepo)?

Yes, but adjust the build context in build-push-action to point to the specific application directory. Also use path filters in the trigger so the workflow only runs when files for that application change.

How much do GitHub Actions minutes cost?

Public repositories get unlimited minutes. Private repos include 2,000 minutes per month on the Free plan, 3,000 on Pro, and 10,000 on Team. A 5-minute build running 20 deploys per month consumes 100 minutes.

Do I pay extra on Guara Cloud for CI/CD?

No. The registry and deploy API are part of your plan. You only pay for container execution time, same as manual deploys.

How do I roll back if an automated deploy broke production?

Each deploy uses the commit SHA as the image tag. In the Guara Cloud panel, select the previous version and click Rollback. Or revert the commit on main and the pipeline will automatically deploy the corrected version.

Can I have staging and production environments in the same pipeline?

Yes. Add a separate staging job that runs first, and make the production job depend on it with needs: [staging]. Use different branches (develop for staging, main for production).

What to do next

With the pipeline running, the typical next steps are adding integration tests to the validation stage and setting up preview deployments for each PR. Guara Cloud supports ephemeral services that you can spin up and tear down automatically.

Deploy your application in Brazil

Containers with HTTPS, public domain, and billing in BRL. No surprises on your invoice.

Create free account