docker build && docker push a partir do seu computador funciona até outra pessoa precisar publicar a mesma imagem, ou até isso precisar acontecer a cada merge sem você estar no teclado. O GitHub Actions executa essa build nos runners do GitHub, marca a tag da imagem de acordo com o que disparou o workflow e a publica em um registry de onde a sua etapa de deploy pode fazer pull. Nada roda localmente.

O que é preciso para construir e publicar uma imagem Docker a partir da CI

Cinco peças precisam estar no lugar antes de um runner conseguir publicar uma imagem:

  • actions/checkout para o contexto de build: o seu Dockerfile e o seu código-fonte
  • docker/setup-buildx-action para um builder BuildKit, porque o Docker Engine padrão do runner não consegue exportar cache de camadas nem construir para outras plataformas
  • docker/login-action para as credenciais do registry
  • docker/metadata-action para transformar o ref do Git em uma lista de tags e labels OCI
  • docker/build-push-action para executar build e push em uma única chamada

O Docker mantém as cinco, e elas foram feitas para se encaixar: as saídas da etapa de metadata entram direto nas entradas da etapa de build. Por baixo, é o mesmo docker build e docker push que você roda manualmente, só que movido para um runner e conectado a eventos do Git.

Por que o runner precisa do Buildx antes de conseguir construir qualquer coisa

O ubuntu-latest já vem com o Docker Engine, que constrói imagens sem problema com docker build. O que ele não consegue fazer sozinho é exportar um cache de build para outro lugar além do disco local, ou construir para uma plataforma diferente da do runner. O docker/setup-buildx-action cria um builder Buildx apoiado no BuildKit e o define como padrão — é isso que torna possíveis o cache-to/cache-from e os builds multiplataforma mais adiante neste guia.

1
2
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v4

Uma única etapa, sem entradas necessárias no caso comum. Se você pular essa etapa, o build-push-action ainda roda, usando o builder que o runner já tem — a build funciona, mas exportação de cache e multi-arch ficam fora de alcance.

Como autenticar no GHCR com o token embutido

Cada execução do workflow recebe um GITHUB_TOKEN de curta duração, com o escopo limitado ao repositório em que roda. Para publicar no GitHub Container Registry (ghcr.io), esse token já basta: sem personal access token, sem secret para criar ou rotacionar.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
permissions:
  contents: read
  packages: write

steps:
  - name: Log in to GHCR
    uses: docker/login-action@v4
    with:
      registry: ghcr.io
      username: ${{ github.actor }}
      password: ${{ secrets.GITHUB_TOKEN }}

O bloco permissions importa tanto quanto a etapa de login. Sem packages: write, o token que o GitHub entrega ao workflow consegue ler mas não publicar, então o login funciona e o push falha com 403. Em um repositório de organização há um segundo lugar para checar: uma configuração de permissões de workflow em Settings → Actions → General pode limitar os tokens a somente leitura independentemente do que o arquivo do workflow pedir. Se o push continuar falhando com o bloco acima já no lugar, verifique isso.

Como marcar tags nas imagens automaticamente a partir do ref do Git

Fixar uma tag no arquivo do workflow significa editar o YAML toda vez que você lançar uma release. O docker/metadata-action lê o evento que disparou a execução (qual branch, qual tag, qual PR) e produz a lista de tags e os labels OCI para você.

1
2
3
4
5
6
7
8
9
- name: Extract metadata
  id: meta
  uses: docker/metadata-action@v6
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=semver,pattern={{version}}
      type=raw,value=latest,enable={{is_default_branch}}
      type=sha,format=short

Você publica uma tag v1.4.0 e a regra semver produz 1.4.0. Você faz push para main e a regra raw adiciona latest. Toda build recebe uma tag baseada no short-SHA independentemente do gatilho, então um commit específico continua disponível para pull mesmo depois de latest ter avançado além dele. steps.meta.outputs.tags é uma lista de tags separadas por quebra de linha produzida por essas três regras para esse único evento, não a união das três ao longo de todas as execuções.

Como fica o workflow completo

Junte as peças e adicione o docker/build-push-action, que executa o docker build e o docker push em uma única chamada BuildKit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
name: Build and push image

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Log in to GHCR
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=raw,value=latest,enable={{is_default_branch}}
            type=sha,format=short

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

context: . constrói a partir do repositório recém-baixado usando o Dockerfile que encontra ali; aponte file: para outro caminho se o seu estiver em outro lugar. O que acaba dentro da imagem continua sendo tarefa do Dockerfile, desde a ordem das camadas até um USER non-root. Esse workflow só decide quando a build roda e para onde vai o resultado.

Uma coisa para resolver logo de início: se a própria build precisar de uma credencial, como um token para um registry npm privado, passe-a pela entrada secrets: da action em vez de um build-arg, que fica visível no histórico da imagem. Variáveis de Ambiente e Secrets no Docker explica essa diferença.

Como colocar camadas em cache entre execuções do workflow

Um runner hospedado pelo GitHub começa limpo toda vez. Sem cache externo, toda camada é reconstruída a cada execução, RUN npm ci incluído. cache-from: type=gha e cache-to: type=gha,mode=max na etapa acima dizem ao BuildKit para ler e gravar seu cache pelo serviço de cache do GitHub Actions em vez do disco local, então o cache sobrevive ao runner.

mode=max coloca em cache toda camada intermediária, incluindo as de estágios anteriores de um Dockerfile multi-stage. O padrão, mode=min, mantém só as camadas que acabam na imagem final, o que significa que o estágio de build de um Dockerfile multi-stage recompila do zero em toda execução.

O cache do Actions tem um teto de tamanho por repositório e remove entradas antigas por lógica LRU. Para a maioria das imagens de aplicação isso nunca chega a ser um problema. Um repositório que constrói várias imagens multi-arch grandes pode atingir esse limite, e um cache apoiado em registry (cache-to: type=registry,ref=ghcr.io/org/app:buildcache) é a saída, já que não é limitado pela cota do cache do Actions.

Como construir a cada push mas publicar só na release

O workflow acima publica a cada push para main e a cada tag de versão, o que é o padrão certo para um projeto que faz deploy a partir de main. Uma configuração mais conservadora constrói e testa cada pull request sem publicar nada, e só publica depois de um merge:

1
2
3
4
5
6
7
8
9
on:
  pull_request:
  push:
    branches: [main]
    tags: ["v*.*.*"]

# na etapa build-push-action:
with:
  push: ${{ github.event_name != 'pull_request' }}

A build completa continua rodando na PR, então um Dockerfile quebrado falha antes do merge, enquanto push: false pula a escrita no registry. Com cache-to: type=gha já no lugar, essa build da PR também prepara o cache que o push pós-merge vai reaproveitar.

O que muda se você publicar no Docker Hub em vez do GHCR

O Docker Hub precisa de um argumento de registry diferente e de credenciais diferentes: um access token vindo de Account Settings → Security, não a senha da sua conta, que o Docker Hub não aceita mais para logins via API.

1
2
3
4
5
6
7
8
- name: Log in to Docker Hub
  uses: docker/login-action@v4
  with:
    username: ${{ vars.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}

# na entrada images: do metadata-action, isso vira:
images: docker.io/yourusername/app

A configuração do Buildx, a extração de metadata e a etapa de build-push não mudam. O registry é um parâmetro, não um pipeline diferente.

O que esse workflow não cobre

Imagens multiplataforma (linux/amd64 e linux/arm64 em um único manifest) precisam do docker/setup-qemu-action antes do Buildx e de uma entrada platforms: na etapa de build. A emulação QEMU deixa uma build arm64 em um runner amd64 lenta, muitas vezes várias vezes mais lenta que a nativa. Se você constrói arm64 regularmente, os runners arm64 hospedados pelo GitHub eliminam a emulação por completo.

Runners self-hosted que já têm o Buildx configurado não precisam do setup-buildx-action, e um builder persistente ali pode superar o backend de cache do Actions mantendo as camadas em disco local entre execuções. Um monorepo que publica três imagens de serviço costuma migrar para uma matrix de build em vez de três cópias desse job, o que muda como context e images são parametrizados, mas não as etapas em si.

Teste o workflow antes de marcar uma release

Faça push primeiro para um branch, não para uma tag de versão. Veja a execução na aba Actions, depois confira a imagem na página Packages do seu repositório, ou faça pull direto com docker pull ghcr.io/<owner>/<repo>:<short-sha> usando a tag do log da execução. Assim que esse pull funcionar, uma release é o mesmo git tag v1.0.0 && git push --tags que você já usa, e o workflow cuida do resto a partir daí.

Artigos relacionados