Makefiles en proyectos Go: lo justo y necesario
Go tiene go build, go test, go run. No necesita un sistema de build. Pero en cuanto tu proyecto crece un poco, acabas con comandos que no son triviales de recordar: flags de compilación, variables de entorno para tests, linters, generación de código, deploys. Un Makefile es la forma más simple de documentar y ejecutar esos comandos.
El Makefile mínimo

.PHONY: build test lint run clean
build:
go build -o bin/server ./cmd/server
test:
go test ./...
lint:
golangci-lint run
run:
API_TOKEN=dev go run ./cmd/server
clean:
rm -rf bin/ tmp/
Cinco targets. Cinco comandos. Nada más. Con esto puedes hacer make build, make test, make run y make lint sin recordar nada. El .PHONY le dice a Make que estos targets no son ficheros sino acciones.
Variables
Si repites valores, usa variables:
BINARY := bin/server
CMD := ./cmd/server
GO := go
.PHONY: build test lint run
build:
$(GO) build -o $(BINARY) $(CMD)
test:
$(GO) test ./...
lint:
golangci-lint run
run: build
API_TOKEN=dev $(BINARY)
Nota que run depende de build. Cuando haces make run, primero compila y luego ejecuta. Make solo recompila si los fuentes han cambiado.
Información del build

Puedes inyectar información en el binario en tiempo de compilación con -ldflags:
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)
build:
go build -ldflags "$(LDFLAGS)" -o bin/server ./cmd/server
En tu main.go:
var (
version = "dev"
commit = "none"
buildDate = "unknown"
)
func main() {
slog.Info("starting", "version", version, "commit", commit, "build_date", buildDate)
// ...
}
Cada binario compilado sabe exactamente de qué commit viene y cuándo se compiló. Invaluable para debugging en producción.
Tests con cobertura
test:
go test ./... -count=1
cover:
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
@echo "Abierto coverage.html en el navegador"
test-race:
go test ./... -race -count=1
-count=1 desactiva la cache de tests para que siempre se ejecuten. -race activa el detector de race conditions, que es lento pero encuentra bugs de concurrencia que de otra forma son invisibles.
Deploy

SERVER := [email protected]
DEPLOY_PATH := /opt/blog
deploy-dry:
rsync -avzn --delete \
--exclude='.git' --exclude='*.go' --exclude='go.*' \
--exclude='cmd' --exclude='internal' --exclude='deploy' \
--exclude='.env' --exclude='content/views.json' \
./ $(SERVER):$(DEPLOY_PATH)/
deploy: deploy-dry
@read -p "¿Continuar con el deploy? (s/n) " ans; \
if [ "$$ans" = "s" ]; then \
rsync -avz --delete \
--exclude='.git' --exclude='*.go' --exclude='go.*' \
--exclude='cmd' --exclude='internal' --exclude='deploy' \
--exclude='.env' --exclude='content/views.json' \
./ $(SERVER):$(DEPLOY_PATH)/; \
ssh $(SERVER) 'systemctl restart blog'; \
echo "Deploy completado."; \
fi
reload:
curl -s -X POST -H "Authorization: Bearer $${API_TOKEN}" \
https://javiervalencia.net/api/v1/reload | jq .
make deploy primero hace un dry-run, luego pide confirmación. Si dices sí, sincroniza y reinicia el servicio. make reload recarga el contenido sin reiniciar, útil cuando solo has subido un post nuevo.
Docker (si lo necesitas)
DOCKER_IMAGE := blog
DOCKER_TAG := $(VERSION)
docker-build:
docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) .
docker tag $(DOCKER_IMAGE):$(DOCKER_TAG) $(DOCKER_IMAGE):latest
docker-run:
docker run --rm -p 8080:8080 --env-file .env $(DOCKER_IMAGE):latest
No uso Docker para este blog, pero si tu proyecto lo necesita, tener los comandos en el Makefile evita recordar las flags de docker build cada vez.
Help automático
Un truco útil: generar la ayuda automáticamente a partir de comentarios:
.DEFAULT_GOAL := help
help: ## Mostrar esta ayuda
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
build: ## Compilar el binario
go build -o bin/server ./cmd/server
test: ## Ejecutar tests
go test ./...
lint: ## Ejecutar linter
golangci-lint run
run: ## Arrancar en desarrollo
API_TOKEN=dev go run ./cmd/server
deploy: ## Desplegar a producción
# ...
Ahora make sin argumentos muestra la lista de targets con su descripción. Es documentación que se mantiene sola.
Lo que NO debe hacer un Makefile
He visto Makefiles de 500 líneas con lógica condicional, funciones de shell anidadas, variables computadas con tres niveles de indirección y targets que nadie recuerda para qué sirven. Eso no es un Makefile: es un script de shell disfrazado.
Un buen Makefile para un proyecto Go debería:
- Caber en una pantalla (máximo 50-60 líneas).
- Tener targets que un humano pueda recordar.
- No duplicar lo que Go ya hace bien (
go generate,go vet,go mod tidy). - No tener lógica compleja. Si necesitas un
ifdentro de un target, probablemente necesitas un script de shell separado.
El Makefile es un índice de comandos, no un sistema de build. Go ya tiene un sistema de build excelente. El Makefile solo le pone nombres fáciles de recordar a los comandos que usas todos los días.
Conclusión
Un Makefile de 30 líneas cubre el 90% de lo que necesitas en un proyecto Go. Build, test, lint, run, deploy. Lo justo. Ni más ni menos. Cada target es un comando que puedes leer y entender en un segundo. Eso es lo que debería ser un Makefile: obvio, predecible y útil.