♂Programming on Marsby Andre Lucas
/
HomelabKubernetesK3sTerraformTailscaleGitHub Actions

Construindo e aplicando IaC no meu homelab

Primeira etapa da construção do meu homelab: K3s em VMs Multipass, infraestrutura com Terraform, acesso privado via Tailscale e automação com GitHub Actions.

Andre Lucas

Programming on Mars by Andre Lucas

September 17, 2026

Sobre

Construindo e aplicando IaC no meu homelab

Este é o primeiro hands-on de uma série sobre a construção do meu homelab. Quero registrar a evolução do ambiente desde o servidor físico até as camadas de plataforma, observabilidade, segurança e aplicações.

A proposta é documentar as decisões de arquitetura, as ferramentas escolhidas, os problemas encontrados e o que precisei mudar pelo caminho. Meu objetivo é automatizar o máximo possível da infraestrutura e manter um ambiente no qual eu possa experimentar Kubernetes, observabilidade, segurança e comunicação entre aplicações.

O primeiro desafio: como o GitHub Actions chega ao meu servidor?

O primeiro desafio foi descobrir como um workflow do GitHub conseguiria acessar o servidor que está aqui em casa para executar um simples terraform apply.

Atualmente, uso o Tailscale para conectar meus dispositivos por meio de uma rede privada. Resumindo, ele cria uma tailnet na qual os equipamentos autorizados conseguem se comunicar sem que eu precise expor diretamente o SSH ou outros serviços do servidor para a internet.

Mas como fazer o GitHub chegar até lá?

Para resolver isso, usei a action tailscale/github-action@v4. A partir do TS_OAUTH_CLIENT_ID e do TS_AUDIENCE, configurados como secrets no GitHub, o runner recebe permissão para entrar na minha rede privada com a tag tag:ci.

- name: Tailscale
  uses: tailscale/github-action@v4
  with:
    oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
    audience: ${{ secrets.TS_AUDIENCE }}
    tags: tag:ci

Depois de entrar na tailnet, o workflow verifica se o servidor andrelab está acessível. Estando tudo certo, ele abre uma sessão com Tailscale SSH e executa o Terraform diretamente no servidor.

O processo atualiza o repositório local com a versão mais recente da branch main, executa o terraform init, gera o plano e aplica as mudanças:

cd ~/homelab
git fetch --all --prune
git reset --hard origin/main

cd infra
terraform init -input=false
terraform plan -input=false
terraform apply -auto-approve -input=false

A chave usada para adicionar as VMs ao Tailscale fica nos secrets do GitHub. Ela é enviada para o processo remoto pela entrada padrão e chega ao Terraform por meio da variável sensível TF_VAR_tailscale_authkey, sem ser gravada no repositório.

Com isso, o fluxo ficou assim:

  1. Um push é feito na branch main.
  2. O GitHub Actions conecta o runner à tailnet.
  3. O workflow verifica a conectividade com o servidor físico.
  4. O runner acessa o servidor usando Tailscale SSH.
  5. O Terraform cria ou atualiza a infraestrutura local.

Arquitetura AS IS

Este é o retrato da arquitetura nesta etapa do laboratório. Vou atualizar a série conforme o ambiente evoluir.

+------------------------------+                  +---------------------------------------------------------------------+
|            GitHub            |                  |                          Tailscale Network                          |
|                              |                  |                                                                     |
|                              |                  |   +--------------------------+                                      |
|                              |                  |   |       K8s Cluster        |                                      |
|  +------------------------+  |                  |   |                          |                                      |
|  |        Workflow        |  |--- SSH --------->|   |      +------+  +------+  |       +------------+  +------------+ |
|  +------------------------+  | Terraform Apply  |   |      |  VM  |  |  VM  |  |       |            |  |            | |
|                              |                  |   |      +------+  +------+  |       |   Laptop   |  | Smartphone | |
|                              |                  |   |                          |       |            |  |            | |
|                              |                  |   |      +------+  +------+  |       +------------+  +------------+ |
|                              |                  |   |      |  VM  |  |  VM  |  |                                      |
|                              |                  |   |      +------+  +------+  |                                      |
|                              |                  |   |                          |                                      |
|                              |                  |   +--------------------------+                                      |
|                              |                  |                                                                     |
+------------------------------+                  +---------------------------------------------------------------------+
  

O objetivo final é ter um cluster Kubernetes para os meus laboratórios, com alguns microsserviços, um API Gateway para o tráfego norte-sul, um service mesh para o tráfego leste-oeste, bancos de dados, event broker e Vault.

Neste primeiro momento, meu foco foi automatizar a criação do cluster. Até aqui, o Terraform já provisiona:

  • uma VM para o control plane do K3s;
  • duas VMs para os workers;
  • Ubuntu Server 24.04 em todas as máquinas;
  • Tailscale em todos os nós;
  • instalação e configuração automática do cluster K3s;
  • geração de um kubeconfig que usa o IP do Tailscale.

Como estou fazendo tudo em bare metal, instalei o Ubuntu Server 24.04 no servidor físico. Nele, uso o Multipass para criar as VMs, enquanto o Terraform fica responsável por declarar e manter toda essa infraestrutura.

Conhecendo o cloud-init

Para provisionar as VMs, estou usando o provider todoroff/multipass:

multipass = {
  source  = "todoroff/multipass"
  version = "~> 1.4"
}

Com esse provider, consigo passar uma configuração de cloud-init para cada instância. Assim, logo no primeiro boot, a VM instala os pacotes necessários, entra na tailnet e instala o K3s sem que eu precise configurar tudo manualmente.

No control plane, o template recebe dois valores gerados e fornecidos pelo Terraform:

cloud_init = templatefile("${path.module}/cloud_init/master.yaml.tpl", {
  k3s_token         = random_password.k3s_token.result
  tailscale_authkey = var.tailscale_authkey
})

O tailscale_authkey é usado somente durante o provisionamento. Dentro da VM, ele é gravado temporariamente em /run/tailscale-authkey, com a permissão 0600. Depois de ser usado pelo comando tailscale up, o arquivo é removido.

Já o k3s_token é uma senha aleatória de 48 caracteres criada pelo Terraform. O mesmo token é enviado para o control plane e para os workers, permitindo que todos os nós entrem no mesmo cluster.

resource "random_password" "k3s_token" {
  length  = 48
  special = false
}

Criando o control plane

Bom, a primeira receita de cloud-init é a do control plane. Nela, instalo as dependências, configuro o Tailscale e só depois instalo o servidor K3s:

#cloud-config
package_update: true

packages:
  - apt-transport-https
  - ca-certificates
  - curl
  - containerd

write_files:
  - path: /run/tailscale-authkey
    permissions: '0600'
    owner: root:root
    content: "${tailscale_authkey}"

runcmd:
  - curl -fsSL https://tailscale.com/install.sh -o /tmp/tailscale-install.sh
  - sh /tmp/tailscale-install.sh
  - systemctl enable --now tailscaled
  - tailscale up --authkey="$(cat /run/tailscale-authkey)" --hostname=$(hostname) --accept-dns=false
  - rm -f /run/tailscale-authkey
  - curl -sfL https://get.k3s.io -o /tmp/k3s-install.sh
  - K3S_TOKEN=${k3s_token} INSTALL_K3S_EXEC="--tls-san=$(tailscale ip -4)" sh /tmp/k3s-install.sh

Essa ordem é importante porque o IP do Tailscale precisa ser incluído como tls-san no certificado da API do Kubernetes. Com isso, consigo acessar a API do cluster pela tailnet sem receber erro de certificado.

A VM do control plane tem 2 CPUs, 4 GB de memória e 20 GB de disco:

resource "multipass_instance" "k8s-master" {
  name   = "k8s-master"
  cpus   = "2"
  memory = "4G"
  image  = "24.04"
  disk   = "20G"

  cloud_init = templatefile("${path.module}/cloud_init/master.yaml.tpl", {
    k3s_token         = random_password.k3s_token.result
    tailscale_authkey = var.tailscale_authkey
  })

  wait_for_cloud_init = true
}

O wait_for_cloud_init = true faz o Terraform esperar o fim da configuração inicial antes de seguir para os recursos que dependem do cluster.

Adicionando os workers

Depois de criar o control plane, chegou a hora de adicionar os workers. O Terraform cria duas VMs com a mesma quantidade de CPU, memória e disco:

resource "multipass_instance" "k8s-worker" {
  count  = 2
  name   = "k8s-worker-${count.index}"
  cpus   = "2"
  memory = "4G"
  image  = "24.04"
  disk   = "20G"

  cloud_init = templatefile("${path.module}/cloud_init/worker.yaml.tpl", {
    master_ip         = multipass_instance.k8s-master.ipv4[0]
    k3s_token         = random_password.k3s_token.result
    tailscale_authkey = var.tailscale_authkey
  })

  depends_on = [multipass_instance.k8s-master]
}

Cada worker recebe o IP privado da VM master e o token compartilhado. No cloud-init, esses dados são usados para instalar o agente do K3s e conectá-lo ao cluster:

K3S_URL=https://${master_ip}:6443 K3S_TOKEN=${k3s_token} sh /tmp/k3s-install.sh

Aqui, o depends_on garante que o Terraform só comece a criar os workers depois que o control plane estiver disponível.

Gerando o kubeconfig pela tailnet

Depois que o master está pronto, ainda preciso conseguir acessar o cluster. Para isso, o Terraform lê o arquivo /etc/rancher/k3s/k3s.yaml de dentro da VM. Como o kubeconfig original aponta para 127.0.0.1, substituo esse endereço pelo IP do Tailscale no master.

data "external" "kubeconfig" {
  depends_on = [multipass_instance.k8s-master]

  program = ["bash", "-c", <<-EOT
    set -euo pipefail

    TS_IP=$(multipass exec k8s-master -- tailscale ip -4 | tr -d '\r\n')
    [ -n "$TS_IP" ] || { echo "tailscale nao retornou IPv4 no master" >&2; exit 1; }

    KC=$(multipass exec k8s-master -- sudo cat /etc/rancher/k3s/k3s.yaml \
         | sed "s#127\.0\.0\.1#$TS_IP#")

    jq -n --arg raw "$KC" '{raw: $raw}'
  EOT
  ]
}

resource "local_sensitive_file" "kubeconfig" {
  content         = data.external.kubeconfig.result.raw
  filename        = "${path.module}/${var.kubeconfig_path}"
  file_permission = "0600"
}

O resultado é salvo como um arquivo sensível, com a permissão 0600. Além de permitir o acesso ao cluster pela rede privada, esse kubeconfig poderá ser usado pelos providers Kubernetes e Helm nas próximas etapas do projeto.

Estado do Terraform

O projeto usa um backend remoto em um bucket da Oracle Cloud Infrastructure (OCI), no objeto homelab/terraform.tfstate. Optei por usar essa cloud porque atualmente trabalho com ela.

A importância de deixar esse estado fora da máquina é não depender apenas do disco do meu servidor e também evitar que ele seja salvo no repositório. Isso ajuda a tornar as execuções mais consistentes.

Resultado desta primeira etapa

Até aqui, saí de um servidor Ubuntu vazio para um cluster K3s com três nós, conectividade privada pelo Tailscale e provisionamento automatizado com GitHub Actions e Terraform.

A base está pronta. No próximo hands-on da série, vou entrar na camada de observabilidade e mostrar como estou instalando e configurando os primeiros componentes dentro do cluster.

Conforme a arquitetura evoluir, vou registrar por aqui as decisões que funcionaram, os erros que exigiram mudanças e os aprendizados de cada etapa.

Tags

HomelabKubernetesK3sTerraformTailscaleGitHub Actions
← Back to home