github-actionsself-hosted-runnerdockerci-cddevopsvps

Runner self-hosted do GitHub Actions com Docker: o setup que sobrevive a billing

Douglas Haruo 11 min 15/09/2026

Um runner self-hosted do GitHub Actions é o agente oficial do Actions instalado numa máquina sua. Os minutos que rodam nele não custam nada: “o uso do GitHub Actions é gratuito para runners self-hosted”, diz a documentação de billing (conferida em 25/08/2026). O setup que funciona numa VPS pequena cabe em cinco decisões. O runner roda como serviço systemd sob um usuário comum, sem privilégio. Toda ferramenta de build roda em contêiner Docker, nunca instalada no host. Cada repositório tem o seu runner, com rótulo próprio. O deploy ganha root por exatamente um script, autorizado no sudoers. E só repositório privado aponta para ele: a documentação de hardening do GitHub manda “quase nunca” usar runner self-hosted em repositório público.

Não chegamos a esse desenho por elegância. Chegamos porque a cota de minutos hospedados da conta acabou e o CI inteiro passou a morrer em silêncio. A anatomia desse apagão (o que parou, o que congelou e o que ninguém viu) está no post irmão, quando o GitHub Actions morre por billing. Este aqui é o guia do que ficou de pé: o desenho que hoje roda CI e deploy de três repositórios numa VPS só.


O que é um runner self-hosted do GitHub Actions?

É o runner oficial, o mesmo programa que roda nas máquinas do GitHub, instalado numa máquina que você controla. Ele fica em polling: pergunta ao GitHub se há job para o repositório onde foi registrado. O job cai nele quando o workflow, o arquivo que descreve esse job, declara os rótulos certos:

jobs:
  ci:
    runs-on: [self-hosted, meu-repo]

Dois fatos práticos, os dois conferidos na documentação em 25/08/2026:

  • A máquina só precisa de conexão HTTPS de saída na porta 443 (requisitos de comunicação). Nenhuma porta de entrada. Isso combina com uma VPS sem porta aberta atrás de Cloudflare Tunnel: o runner conversa para fora, como todo o resto da caixa.
  • O medidor de minutos só existe para runner hospedado em repositório privado (2.000 min/mês no plano Free). O minuto de Windows e o de macOS custam mais caro que o de Linux. Runner self-hosted é grátis; repositório público em runner hospedado padrão também é (billing). Essa tabela de preços decide sozinha a topologia inteira deste post. E a seção de segurança mostra que ela coincide com a topologia segura.

Como instalar o runner numa VPS como serviço?

O caminho todo é a página do próprio repositório: Settings → Actions → Runners → New self-hosted runner. Ela gera os comandos exatos de download e registro, com a versão atual e um token de registro de curta duração. Esse token serve só para o ./config.sh e não vira segredo de longa vida na máquina. Depois do registro, o runner mantém as próprias credenciais. O que a página não decide por você é o que importa:

# usuário próprio, comum, SEM sudo — o runner não roda como root
sudo useradd -m runner
sudo -iu runner
mkdir actions-runner && cd actions-runner
# (download + checksum: copiar da página "New self-hosted runner" do repo)
./config.sh --url https://github.com/<dono>/<repo> --token <token-da-pagina> \
  --labels meu-repo
exit

# serviço systemd, instalado para AQUELE usuário — sobrevive a reboot
cd /home/runner/actions-runner
sudo ./svc.sh install runner
sudo ./svc.sh start

O svc.sh install <usuário> / svc.sh start é o mecanismo oficial para rodar o runner como serviço systemd, aquele que o sistema sobe sozinho (doc conferida em 25/08/2026). A mesma página avisa que, em Debian/Ubuntu com needrestart ativo, é preciso configurá-lo para ignorar o serviço do runner. Senão, um upgrade qualquer reinicia o runner no meio de um job.

O que não fazer já aparece aqui: não rode o runner como root, não dê sudo geral ao usuário dele. O único privilégio que ele vai ganhar é o da seção de deploy: um script, e mais nada.

Por que rodar as ferramentas em contêiner Docker, e nada no host?

O host fica com o mínimo: o runner, docker, git. Node, Python, shellcheck vêm todos numa imagem, escolhida por workflow. O ganho não é teórico. Cada repositório fica com a sua versão, sem conflito. Nada de apt-get acumulando estado na caixa de produção. E o ambiente de build morre com o contêiner em vez de virar arqueologia.

Uma honestidade antes dos padrões: o contêiner aqui é higiene, não sandbox. O workflow controla os comandos docker, então quem escreve no repositório pode montar o que quiser. A fronteira de segurança é outra, a da seção sobre repositório público, abaixo. O contêiner resolve estado e versão, não confiança.

Usamos dois padrões, e a diferença entre eles é quantos steps compartilham estado:

Padrão A: um passo grande, checkout read-only. O checkout entra montado só-leitura em /src e é copiado para dentro do contêiner. O contêiner pode ser root por dentro, porque nada volta para o workspace do runner:

- run: |
    docker run --rm -v "$GITHUB_WORKSPACE":/src:ro -w /repo node:22 \
      bash -eo pipefail -c '
        cp -a /src/. /repo/
        git config --global --add safe.directory /repo
        npm ci --no-audit --no-fund
        npm test'

Duas pegadinhas que custaram runs de verdade. A primeira: o .git copiado pertence ao usuário do runner, e dentro do contêiner você é root. Sem o safe.directory, qualquer comando git morre com detected dubious ownership. E isso não reproduz no Docker do macOS, que mapeia o dono de bind mounts, as pastas do host montadas no contêiner; só o runner Linux mostra. A segunda é a imagem: use node:22 em vez de node:22-slim quando o npm ci compila addon nativo (pede python3/make/g++) ou quando algum passo anda pelo histórico do git.

Padrão B: vários steps compartilhando estado, montagem read-write com --user. Quando os steps trocam node_modules e dist entre si, copiar a cada passo não serve. A montagem vira read-write e o contêiner roda com o uid:gid do usuário do runner. Assim nenhum arquivo de root sobra no workspace, e arquivo de root no workspace é o que quebra o npm ci do run seguinte:

env:
  DOCKER_NODE: >-
    docker run --rm
    --user 1000:1000
    -e HOME=/tmp
    -e npm_config_cache=/w/.npm-cache
    -v ${{ github.workspace }}:/w -w /w
    node:22
steps:
  - uses: actions/checkout@v4
  - run: $DOCKER_NODE npm ci --no-audit --no-fund
  - run: $DOCKER_NODE npx astro check
  - run: $DOCKER_NODE npm run build

Três pegadinhas deste padrão, todas pagas em runs vermelhos. A primeira: $(id -u) não expande dentro de valor de env:, porque o YAML não passa por command substitution ali. O uid:gid do usuário do runner entra fixo.

A segunda é HOME=/tmp: o npm exige um HOME gravável, e o home do usuário do runner não existe dentro do contêiner. A terceira: o cache do npm mora dentro do workspace, não num volume nomeado. Volume nomeado nasce pertencendo ao root, o contêiner roda como usuário comum, e o npm morre com error writing to the directory. No workspace o dono está certo, e o cache ainda sobrevive entre runs.

A regra de escolha é curta: uma verificação só, padrão A; steps que compartilham artefatos, padrão B.

Um repositório, um runner: por que três runners na mesma caixa?

Porque numa conta pessoal não há escolha. Runner registrado no repositório atende só aquele repositório. Compartilhar runners entre repositórios é recurso de organização: os runner groups existem no nível da organização (conferido em 25/08/2026), não no da conta pessoal.

O resultado aqui: três repositórios vivos, três runners na mesma VPS, três serviços systemd para atualizar. É um custo consciente, anotado no PR que o criou. Redescobrir daqui a seis meses por que a caixa tem três units parecidas custaria mais.

Dois efeitos colaterais que valem saber antes:

  • Rótulos são conjunção, não alternativa. runs-on: [self-hosted, meu-repo] exige um runner com os dois rótulos. Dar a cada runner um rótulo distintivo documenta onde o job deve cair. E protege contra o dia em que houver um segundo runner no mesmo repositório.
  • Um runner roda um job por vez. Isso serializa deploys de graça: dois pushes não se atropelam. E cobra o preço no espelho: rodar a mesma suíte em dois workflows que disputam o mesmo runner é pagar duas vezes em fila. Foi por essa razão que deduplicamos o job de testes que rodava no push e de novo dentro do pipeline de deploy.

Como fica o deploy com rollback?

O runner mora na caixa de produção. Então “deploy” deixa de ser ssh e vira rodar um script local, liberado pelo CI:

deploy:
  needs: build-and-test
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  runs-on: [self-hosted, meu-repo]
  timeout-minutes: 30
  concurrency:
    group: deploy
    cancel-in-progress: false

A fronteira de privilégio é o coração do desenho. O usuário do runner não tem sudo, exceto para um script, de root, fora do checkout:

# o script é instalado por root, fora do diretório que o runner escreve
install -o root -g root -m 0755 deploy/ci-deploy.sh /opt/app/ci-deploy.sh
echo 'runner ALL=(root) NOPASSWD: /opt/app/ci-deploy.sh' > /etc/sudoers.d/ci-deploy
chmod 440 /etc/sudoers.d/ci-deploy && visudo -c

“Fora do checkout” não é capricho. O sudoers é o arquivo que diz quem pode rodar o quê como root. Se ele apontasse para um arquivo que o usuário do runner consegue escrever, o “sudo restrito” seria root geral com passos extras. E o modelo de confiança resultante merece ser dito em voz alta: quem escreve em main deploya. Proteja main, deixe o job de testes na frente, e aceite que essa é a fronteira real.

O script em si segue o roteiro clássico: snapshot do estado atual, sincroniza o checkout testado, up -d --build --wait, healthcheck profundo (a checagem de saúde do serviço). Se qualquer perna falhar, vem o rollback: volta ao snapshot. Três detalhes do workflow em volta custaram aprendizado:

  • Ler o script inteiro para a memória antes de executar (bash -c "$(cat /opt/app/redeploy.sh)"). Um deploy que atualiza o próprio script de deploy não pode puxar o tapete de baixo do bash no meio do run.
  • timeout-minutes cobre as duas pernas. O pior caso é build + espera de healthcheck e depois rebuild de rollback + espera de novo. Um timeout que só cabe a ida mata o rollback no meio, que é exatamente o momento em que você mais precisa dele.
  • cancel-in-progress é bom no CI e péssimo no deploy. Cancelar o run superado de uma branch economiza fila; cancelar um redeploy no meio do --wait pode deixar um container doente no ar sem rollback. O grupo de concurrency do deploy serializa sem cancelar.

Runner self-hosted em repositório público é seguro?

Não. E essa não é uma opinião nossa, é a posição da documentação oficial. O guia de hardening do GitHub, a página de segurança da própria plataforma, foi conferido em 25/08/2026. Ele diz que runners self-hosted “quase nunca deveriam ser usados em repositórios públicos no GitHub, porque qualquer usuário pode abrir pull requests contra o repositório e comprometer o ambiente”. No original: “Self-hosted runners should almost never be used for public repositories on GitHub, because any user can open pull requests against the repository and compromise the environment” (security hardening). O mecanismo é direto: um PR de fork traz código, e workflows são código, que executa na sua máquina.

E o estrago não termina com o job. O runner padrão não é efêmero: a mesma doc avisa que o ambiente persiste entre jobs e pode ser “persistentemente comprometido por código não confiável”. Ela avisa também que segredos passados como argumento de linha de comando ficam visíveis a outro job na mesma máquina (um ps x -w basta). Uma vez sujo, o runner continua sujo para todos os jobs seguintes.

As mitigações existem, e vale nomear o que cada uma vale:

  • Aprovação manual para runs de PR de fork. O GitHub pode exigir aprovação de um mantenedor antes de rodar workflows de forks. Reduz a janela, mas não muda a natureza do problema: um clique distraído em “Approve and run” ainda executa o código do estranho na sua caixa.
  • Runners efêmeros / just-in-time. São registrados via API para “executar no máximo um job antes de serem removidos automaticamente”. Resolvem a persistência entre jobs. Não resolvem o job malicioso em si, que roda com o acesso de rede da máquina.

Por isso a recomendação continua sendo a simples: runner self-hosted só em repositório privado. E note que o Docker da seção anterior não muda nada aqui. O workflow controla os comandos docker, então o contêiner é higiene de estado, não proteção contra o próprio repositório.

Aqui a regra fecha com uma simetria que a tabela de preços já tinha sugerido. O único repositório público com CI, o community node do n8n, ficou em ubuntu-latest. Repositório público não consome cota em runner hospedado padrão: não havia motivo de billing para migrar, e havia um motivo de segurança para não migrar. Privado com cota → runner self-hosted; público → runner hospedado, de graça.

A doc de hardening manda fazer uma pergunta que merece resposta escrita no seu repositório: que informação sensível mora na máquina do runner, e a que serviços ela tem acesso de rede? Nossa resposta é “tudo, é a caixa de produção”, de propósito. É isso que torna o deploy local possível. A consequência honesta dessa escolha: escrever em main equivale a mandar na caixa. main protegido é parte do desenho, não decoração.

O que não deve rodar no runner self-hosted?

Além de qualquer workflow de repositório público, uma categoria inteira: o que vigia a própria caixa. O probe de uptime, que vigia se a caixa está no ar, não pôde seguir os outros workflows para o runner. O runner é a máquina vigiada, e uma máquina não reporta a própria queda. Esse pedaço do incidente, e a saída (mover o probe para a borda, longe da caixa e do GitHub), estão contados no post irmão. A regra que fica é a mesma de lá: migre build e teste para o runner, nunca o alarme.

Como não ser pego pela cota de novo?

O runner self-hosted tira os workflows críticos do medidor, mas não desliga o medidor. Qualquer workflow que voltar a ubuntu-latest num repositório privado volta a depender da cota. E a cota, como o incidente provou, esgota sem aviso.

Nossa resposta é uma guarda de cota: um script diário que mede o consumo do mês e avisa aos 80%, antes do apagão. O padrão importa mais que o código:

  • Medir pela API de billing, com o token que já existe. gh api contra o endpoint atual de uso (/users/<você>/settings/billing/usage, somando os minutos de Actions do mês), com fallback no endpoint legado (/users/<você>/settings/billing/actions, que já traz total_minutes_used). Os dois exigem o escopo user no token do gh: um gh auth refresh -h github.com -s user, uma vez, interativo. Nenhum segredo novo entra em lugar nenhum.
  • Três saídas, não duas. OK, aviso e “não consegui medir”. A terceira é a que separa este desenho de um portão silencioso. Sem rede, sem escopo ou sem gh na máquina, o script diz que não mediu em vez de calar. Falha de medição tratada como sucesso é o mesmo bug do CI que morre em 2 segundos, reencenado em shell.
  • Avisar onde alguém já lê. A linha de saída entra no log da rotina noturna de backup, que já tem leitor e já termina pingando um dead-man switch. Um aviso num log que ninguém abre é apenas o velho silêncio com timestamp. Pendurar a guarda numa rotina já vigiada é o que a faz existir de verdade.
  • Avisar também quando já está pagando. Se o mês registra cobrança além do incluído, o aviso dispara independentemente do percentual. A essa altura não é previsão, é fatura.

O resumo que você leva

  1. Minutos de runner self-hosted são gratuitos, e a máquina só precisa de HTTPS de saída na 443. Cabe numa VPS sem porta aberta (documentação conferida em 25/08/2026).
  2. Instale como serviço: usuário próprio sem sudo, ./config.sh com o token de registro da página do repo, sudo ./svc.sh install <usuário> && sudo ./svc.sh start. E needrestart ignorando o serviço em Debian/Ubuntu.
  3. Ferramenta em contêiner, host limpo: checkout read-only + cópia quando o job é um passo só. Montagem read-write com --user (uid fixo, porque $( ) não expande em env:), HOME=/tmp e cache dentro do workspace quando os steps compartilham estado. Contêiner é higiene, não sandbox.
  4. Conta pessoal = um runner por repositório (runner groups são de organização). Rótulo distintivo por runner; um job por vez, o que dá serialização de graça e dedupe de suíte por obrigação.
  5. Deploy com rollback: o runner roda um script local de root autorizado por uma única linha de sudoers, fora do checkout. Timeout que cobre ida e rollback; deploy serializa sem cancel-in-progress. Quem escreve em main deploya. Proteja main.
  6. Repositório público não aponta para runner self-hosted. A doc oficial diz “quase nunca”, porque PR de fork executa código na sua máquina e o ambiente persiste entre jobs; aprovação manual e runners efêmeros mitigam, não resolvem. Público roda de graça no runner hospedado. Use isso.
  7. O alarme não mora na caixa: probe de uptime fica fora do runner, porque a máquina não reporta a própria queda.
  8. Guarda de cota: medir o mês pela API de billing, avisar aos 80% e quando já há cobrança, distinguir “não mediu” de “ok”, e logar onde alguém já lê. A cota esgota sem aviso; o aviso é você quem constrói.

Infraestrutura que escala sem quebrar o orçamento

Conta de cloud fora de controle? Opero a minha própria numa VPS única, sem porta aberta, com deploy automático e rollback por healthcheck. O desenho inteiro está publicado aqui.

Ver os posts de infraestrutura →