Fazendo deploy de uma aplicação web Go na Hetzner
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Um binário Go é excepcionalmente fácil de colocar num servidor: não há interpretador pra instalar, nenhum gerenciador de pacotes pra rodar, nenhuma versão de runtime de linguagem pra fixar no host. Compile uma vez, copie pra lá, e ele roda. Essa simplicidade não remove o resto do trabalho, porém. Você ainda precisa de um servidor, um domínio apontado pra ele, um banco de dados, HTTPS na frente de tudo, um backup que sobrevive ao servidor desaparecer, e um jeito rápido de lançar sua próxima mudança. Este guia constrói tudo isso à mão, e depois mostra o caminho mais curto.
A aplicação em si é deliberadamente pequena: um serviço net/http usando só a biblioteca padrão, com uma rota que lê e escreve no Postgres através do pgx/v5, então existe um motivo real pra falar de uma conexão de banco de dados e não só um “hello world” estático.
O que você vai precisar
- Uma conta Hetzner Cloud e um servidor. O CX23 otimizado por custo (2 vCPU, 4 GB de memória, 40 GB NVMe) está listado a €5,99/mês sem IVA. Cheque o preço e a disponibilidade atuais no Hetzner Cloud Console antes de pedir, já que as linhas de nuvem e os preços mudam.
- Um nome de domínio ao qual você pode adicionar um registro DNS.
- Um par de chaves SSH na sua máquina (
ssh-keygen -t ed25519se você ainda não tiver um). - Go 1.27 ou mais novo instalado localmente, pra escrever e construir a aplicação antes que ela toque o servidor.
- Cerca de quinze minutos pra o servidor e o DNS se acomodarem, a maior parte gasta esperando em vez de digitando.
Passo 1: criar o servidor e apontar um domínio pra ele
No Hetzner Cloud Console, abra (ou crie) um projeto, depois escolha Add Server. Escolha uma localização: Nuremberg, Falkenstein ou Helsinki mantêm seus dados na UE, enquanto Ashburn, Hillsboro ou Singapura também estão disponíveis se seus usuários estiverem em outro lugar. Escolha Ubuntu 24.04 como imagem, depois em tipo de servidor escolha o vCPU compartilhada CX23 (2 vCPU, 4 GB de memória, 40 GB NVMe). Em chaves SSH, adicione a chave pública da sua máquina em vez de depender de uma senha gerada, depois crie o servidor.
Quando ele iniciar, o console mostra seu endereço IPv4 público. Conecte pra confirmar o acesso:
ssh root@YOUR_SERVER_IP
Você deve ver o banner de boas-vindas do Ubuntu e cair num shell root. Se a conexão for recusada, confira se a chave SSH anexada ao servidor combina com a chave privada na sua máquina.
Adicione um registro A no seu provedor de DNS pro hostname que você quer usar, apontando pra esse IP:
Type: A
Name: myapp (or @ for the bare domain)
Value: YOUR_SERVER_IP
TTL: 300
Cheque se ele resolve antes de seguir, já que o Caddy vai precisar disso já funcionando numa etapa posterior:
dig +short myapp.example.com
Você deve ver o IP do seu servidor impresso de volta. Se não aparecer nada, espere alguns minutos e tente de novo.
A Hetzner não coloca um firewall na frente de um servidor novo por padrão, então restrinja o host a só o que você precisa com ufw:
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
Passo 2: instalar o Docker no servidor
O próprio script de instalação do Docker cuida tanto do engine quanto do plugin do Compose numa máquina Ubuntu nova:
apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker
Cheque a instalação:
docker --version
docker compose version
Você deve ver uma versão do Docker e uma versão do Compose impressas, não um erro de “comando não encontrado”.
Passo 3: escrever a aplicação Go
Na sua própria máquina, configure o módulo:
mkdir myapp && cd myapp
go mod init myapp
go get github.com/jackc/pgx/v5
go mod init e go get escrevem um go.mod assim (suas versões exatas de dependência podem diferir um pouco dependendo de quando você rodar isso, já que go mod tidy as resolve no momento da requisição):
module myapp
go 1.27
require github.com/jackc/pgx/v5 v5.7.6
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
Agora o main.go, usando só net/http da biblioteca padrão mais pgxpool pro banco de dados:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
var pool *pgxpool.Pool
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL is not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var err error
pool, err = pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("unable to create connection pool: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
log.Fatalf("unable to reach postgres: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`); err != nil {
log.Fatalf("unable to create visits table: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", handleHealthz)
mux.HandleFunc("/visits", handleVisits)
addr := "0.0.0.0:" + port
log.Printf("listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
func handleHealthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
type visitsResponse struct {
Count int64 `json:"count"`
}
func handleVisits(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if _, err := pool.Exec(ctx, "INSERT INTO visits DEFAULT VALUES"); err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
var count int64
if err := pool.QueryRow(ctx, "SELECT count(*) FROM visits").Scan(&count); err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(visitsResponse{Count: count})
}
Duas coisas importam aqui além dos handlers em si. Primeiro, a aplicação lê PORT do ambiente e cai pra 8080, e se vincula a 0.0.0.0:$PORT, não a localhost:$PORT. O Caddy roda no próprio container e alcança essa aplicação pela rede do Docker pelo nome do container, não pela interface de loopback, então se vincular a localhost significaria que o Caddy nunca conseguiria alcançá-la. Segundo, DATABASE_URL é lido do ambiente em vez de fixado no código, então o exato mesmo binário roda sem mudanças contra um banco local, um banco de staging ou o de produção nesse servidor.
Vale nomear o que está ausente comparado a uma linguagem interpretada. Um deploy de Rails ou Django precisa garantir que a versão certa de Ruby ou Python esteja instalada no servidor e que as gems ou pacotes correspondentes sejam buscados e encontrados nos caminhos que o interpretador espera. Nada disso se aplica aqui. O go build produz um binário com tudo já vinculado dentro; o servidor nem precisa ter Go instalado, só o arquivo compilado. É também por isso que o Dockerfile na próxima etapa tem dois estágios: um pra compilar, um só pra rodar o resultado.
Passo 4: escrever o Dockerfile
FROM golang:1.27.3-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server .
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=build /out/server /usr/local/bin/server
EXPOSE 8080
CMD ["server"]
O primeiro estágio usa golang:1.27.3-alpine pra compilar o binário. CGO_ENABLED=0 desliga o cgo, então o binário resultante não se vincula à biblioteca C do sistema, e GOOS=linux garante um binário Linux mesmo se você estiver construindo num Mac. O segundo estágio parte de um alpine:3.21 puro, bem menor que a imagem de build, e copia só o binário server compilado. O ca-certificates é instalado pra que o binário consiga verificar certificados TLS se o DATABASE_URL algum dia apontar pra uma instância do Postgres exigindo SSL; o pgx não tem nenhuma dependência C uma vez que CGO_ENABLED=0 está definido, então nada mais precisa ser instalado nesse estágio de runtime.
Passo 5: escrever o arquivo do Docker Compose
Copie sua aplicação pro servidor (scp, rsync, ou um git clone do seu repositório em /opt/myapp), crie um arquivo .env com uma senha de banco de dados, depois adicione o compose.yml ao lado dele:
POSTGRES_PASSWORD=a-long-random-password
services:
app:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp?sslmode=disable
PORT: "8080"
depends_on:
db:
condition: service_healthy
expose:
- "8080"
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 10
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
volumes:
db_data:
caddy_data:
caddy_config:
O serviço app alcança o Postgres pelo hostname db, o nome que o Compose dá a esse serviço, não localhost. depends_on: condition: service_healthy evita que a aplicação inicie antes do Postgres de fato poder aceitar conexões, não só antes do container dele existir. Só o caddy publica portas pro host, então o Postgres e a própria aplicação nunca são alcançáveis de fora desse servidor.
Cheque se o arquivo é lido sem erros antes de seguir:
docker compose config
Você deve ver a configuração totalmente resolvida impressa sem nenhuma linha de erro.
Passo 6: escrever o Caddyfile
myapp.example.com {
reverse_proxy app:8080
}
Troque myapp.example.com pelo domínio que você apontou pro servidor no Passo 1, e 8080 por qualquer PORT que você tenha definido em compose.yml se mudar isso. O Caddy redireciona via proxy toda requisição pro container app nessa porta, e solicita um certificado TLS gratuito do Let’s Encrypt pra esse hostname exato, renovando ele automaticamente. Isso só funciona se o domínio já resolver pra esse servidor, que é por isso que o DNS veio antes do Caddy neste guia. Você pode checar se o Caddyfile está bem formado com:
docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile
Você deve ver Valid configuration na saída.
Passo 7: iniciar o stack
A partir de /opt/myapp:
docker compose up -d --build
Isso constrói a imagem, inicia o Postgres, espera ele reportar saudável, depois inicia a aplicação, que conecta ao Postgres e cria sua única tabela no primeiro boot.
Cheque o stack:
docker compose ps
curl -I https://myapp.example.com/healthz
Você deve ver os três serviços como Up (e db como healthy), e o curl deve retornar HTTP/2 200 sem nenhum aviso de certificado. Acessar /visits algumas vezes deve retornar uma contagem crescente:
curl https://myapp.example.com/visits
Passo 8: fazer backup do Postgres
Um dump feito fora do volume de dados é o que realmente te protege se o servidor desaparecer. Adicione um script de backup:
cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh
Agende com cron, diariamente às 3h:
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -
Rode uma vez à mão e cheque que funcionou:
/opt/myapp/backup.sh
ls -lh /opt/myapp/backups
Você deve ver um arquivo .sql.gz com o timestamp de hoje e um tamanho diferente de zero. Pra qualquer coisa além de um projeto pessoal, copie esses arquivos pra fora do servidor também, pra outra máquina ou um armazenamento de objetos, pra que perder o servidor não signifique perder o backup também.
Passo 9: lançar uma atualização
Toda mudança futura segue o mesmo formato: trazer o código novo pro servidor, reconstruir a imagem, substituir o container em execução.
git pull
docker compose build app
docker compose up -d app
docker compose up -d app só recria o container app, não db nem caddy, então o Postgres continua rodando e o estado do certificado do Caddy fica intocado. Existe um intervalo curto, um segundo ou dois, enquanto o container antigo para e o novo inicia, já que essa configuração roda um único container de aplicação.
Vale ser explícito sobre uma coisa que Rails e Django tratam automaticamente e Go não: não existe uma única convenção dominante pra rodar migrations de schema como parte de um deploy. A única tabela deste guia é criada com CREATE TABLE IF NOT EXISTS direto no main.go, o que é suficiente pra algo desse tamanho, mas uma aplicação real com schema evoluindo geralmente recorre a uma ferramenta dedicada. goose e golang-migrate são as duas mais comuns, rodadas como uma etapa explícita, ou embutida no comando de inicialização do container ou rodada à mão antes de docker compose up -d app. Decida por uma e seja consistente, já que nada faz isso implicitamente por você.
Cheque que foi aplicada:
docker compose logs --tail=50 app
curl -I https://myapp.example.com/healthz
Você deve ver sua aplicação inicializar limpa nos logs e o curl ainda retornando 200 na nova versão.
O caminho mais curto: fazer isso com o Railyard
Tudo acima (o servidor, Docker, Postgres, Caddy, backups, uma release repetível) também é o que o Railyard automatiza pra uma aplicação Go na Hetzner, sem você escrever nenhum dos arquivos acima à mão.
O Railyard detecta Go a partir do go.mod, incluindo um módulo que vive numa subpasta em vez da raiz do repositório, e constrói a partir do lugar certo automaticamente. Um import de driver de banco de dados conhecido no seu código, github.com/jackc/pgx, github.com/lib/pq, github.com/go-sql-driver/mysql ou github.com/redis/go-redis, provisiona o banco gerenciado correspondente pra você e injeta DATABASE_URL ou REDIS_URL como variável de ambiente, sem addon pra adicionar à mão antes, a mesma conexão que este guia conectou manualmente no Passo 5. A única exigência continua igual em relação ao Dockerfile manual acima: o binário precisa escutar na $PORT, em todas as interfaces, não só em localhost.
O Go não tem um único framework de fila de jobs dominante do jeito que o Rails tem o Sidekiq ou o Solid Queue, então também não há nada pro Railyard auto-detectar ali. Em vez disso, um processo worker é uma linha numa declaração de processo estilo Procfile:
web: ./server
worker: ./server -mode=worker
As duas linhas rodam o mesmo binário construído; sua própria main() lê uma flag ou variável de ambiente pra decidir qual loop rodar. Cada tipo de processo escala independentemente dos outros.
Cole a URL do seu repositório em https://app.railyard.run/deploy?repo= e escolha a Hetzner como provedor, ou vá direto pelo caminho de deploy de Go na Hetzner. Veja os docs do Go pras regras exatas de detecção. O plano Free do Railyard cobre um servidor mais um servidor de banco de dados e três aplicações sem cartão necessário, o Pro é US$19/mês por equipe, e o Team é US$49/mês por equipe.
Resolução de problemas
O binário construído localmente não roda no servidor. Um erro como exec format error quando o container inicia geralmente significa que o binário foi compilado de forma cruzada pra arquitetura errada, construído num Mac com Apple Silicon sem GOARCH=amd64 e depois copiado direto pra um servidor x86, ou GOOS=linux foi esquecido e o binário só roda na máquina que o construiu. Construir dentro do Dockerfile com CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build, como mostrado no Passo 4, em vez de copiar um binário construído localmente, evita isso já que o build sempre tem como alvo a plataforma certa.
Conexão com o Postgres recusada. Se os logs da aplicação mostrarem could not connect to server: Connection refused ou algo parecido, cheque se DATABASE_URL usa db como hostname, não localhost ou 127.0.0.1. Dentro da rede do Compose, db é o único endereço que resolve pro container do Postgres; localhost significa o próprio container app, que não tem nenhum banco de dados rodando nele.
Porta já em uso. Se docker compose up falha com algo vinculado à porta 80 ou 443, o Apache ou Nginx padrão do Ubuntu pode já estar instalado e rodando. Cheque com ss -tlnp | grep -E ':80|:443' e pare ou remova o serviço que estiver ocupando elas antes do Caddy tentar se vincular.
HTTPS não é emitido. Se docker compose logs caddy mostra erros repetidos de certificado ou NXDOMAIN, seu registro DNS do Passo 1 ainda não propagou ou está apontando pro IP errado. Rode dig +short myapp.example.com de novo, confirme que combina com o servidor, depois reinicie o Caddy com docker compose restart caddy quando combinar.
Falta de memória sob carga. Os 4 GB de memória de um CX23 rodam esse stack com conforto em repouso, mas um leak de goroutine, gerando uma por requisição e nunca deixando ela terminar, por exemplo, pode fazer o uso de memória crescer continuamente até o kernel começar a matar processos. docker stats no servidor mostra se a memória do container app está subindo com o tempo em vez de se estabilizar depois de um pico de tráfego; o perfil de heap do pprof é o próximo passo se estiver.
Perguntas frequentes
Eu preciso de um framework como gin ou echo pra uma aplicação pequena? Não. O net/http e o http.ServeMux da biblioteca padrão tratam o roteamento, e os padrões http.Handler e middleware são Go puro, sem framework necessário, pra qualquer coisa que não esteja lidando com dezenas de rotas ou querendo validação e agrupamento de requisição embutidos.
Como eu adiciono um processo worker? Adicione um segundo serviço ao compose.yml construído a partir da mesma imagem, rodando o mesmo binário com uma flag ou comando diferente, por exemplo ./server -mode=worker em vez de ./server. Ele lê o mesmo DATABASE_URL, só sem se vincular a nenhuma porta.
A abordagem do binário estático realmente importa pra segurança? Ela remove toda uma categoria de problema: nenhuma versão de interpretador com seus próprios CVEs pra corrigir no servidor, e nenhum gerenciador de pacotes resolvendo dependências transitivas no momento do deploy. Vulnerabilidades nas suas próprias dependências Go ainda precisam ser rastreadas, igual em qualquer linguagem, mas a superfície de ataque de “o que está instalado além da minha aplicação” fica perto de zero.
E se a aplicação não tiver nenhum banco de dados? Remova o serviço db do compose.yml por completo, remova DATABASE_URL do ambiente do app, e o resto do guia fica inalterado: construa o binário do mesmo jeito, rode ele por trás do Caddy do mesmo jeito, não faça backup de nada porque não há nada com estado pra fazer backup.
Como funcionam as migrations, já que o Go não tem uma única convenção? Diferente do db:prepare do Rails ou do migrate do Django, nada roda implicitamente. Escolha uma ferramenta, goose e golang-migrate são as duas mais usadas, e rode ela como uma etapa explícita no seu processo de deploy, ou como um comando no entrypoint do container ou como uma etapa manual antes de docker compose up -d app. Pra um schema tão simples quanto esse, CREATE TABLE IF NOT EXISTS na inicialização, como o main.go deste guia faz, é um paliativo razoável, mas não trata alterar uma tabela existente, que é onde uma ferramenta de migration de verdade ganha seu lugar.
Essa é uma configuração completa de produção pra um serviço Go: um servidor, Docker Compose rodando a aplicação e o Postgres, Caddy cuidando do HTTPS, um backup noturno, e uma release de dois comandos. Mantenha compose.yml, Caddyfile e o Dockerfile no seu repositório junto da aplicação, e todo deploy futuro é o mesmo git pull, build, restart que você acabou de rodar no Passo 9.
Por que escolhem o Railyard para isso
- Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
- Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
- Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
- Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
- Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.