Go compila a un único binario estático sin dependencias de runtime, así que desplegarlo en producción consiste en copiar un archivo y gestionarlo bien. En este tutorial compilarás una pequeña API HTTP en Go con versión embebida y apagado ordenado, la ejecutarás como servicio systemd con un usuario sin privilegios, la publicarás detrás de Nginx con un certificado de Let's Encrypt y aprenderás a actualizarla y revertirla en segundos. Todo sobre Ubuntu 24.04.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 1 GB de RAM.
- Un usuario no root con privilegios
sudo. - Un dominio con un registro A apuntando a la IP del servidor. En esta guía se usa
your_domain; sustitúyelo por el tuyo. - Los puertos 80 y 443 accesibles desde Internet.
Paso 1: Instalar Go
El paquete golang-go de Ubuntu 24.04 trae Go 1.22, una versión ya sin soporte. Instala la versión estable actual desde el sitio oficial. Primero consulta cuál es:
GO_VERSION=$(curl -fsSL 'https://go.dev/VERSION?m=text' | head -n1)
echo "$GO_VERSION"
go1.27.1
Descarga el archivo para tu arquitectura (amd64 en la mayoría de servidores, arm64 en servidores ARM) y extráelo en /usr/local/go, borrando antes cualquier instalación previa:
curl -fsSLO "https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz"
sha256sum "${GO_VERSION}.linux-amd64.tar.gz"
Compara el hash con el que publica go.dev/dl para ese archivo y, si coincide, instálalo:
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf "${GO_VERSION}.linux-amd64.tar.gz"
Añade Go al PATH de tu usuario y carga el cambio en la sesión actual:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile
source ~/.profile
go version
go version go1.27.1 linux/amd64
NotaGo solo hace falta en la máquina donde compilas. Si compilas en tu portátil o en CI, el servidor de producción no necesita tener Go instalado.
Paso 2: Crear la aplicación de ejemplo
Crea un directorio para el proyecto e inicializa un módulo:
mkdir -p ~/goapp && cd ~/goapp
go mod init example.com/goapp
Crea el archivo principal:
nano main.go
La aplicación lee la dirección de escucha de la variable LISTEN_ADDR, expone /health con la versión y, al recibir SIGTERM (lo que envía systemctl stop), deja de aceptar conexiones y espera a que terminen las peticiones en curso con http.Server.Shutdown:
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// version se sobrescribe al compilar con -ldflags "-X main.version=..."
var version = "dev"
func main() {
showVersion := flag.Bool("version", false, "muestra la versión y sale")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:8080"
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": version})
})
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hola desde Go")
})
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
// ctx se cancela al recibir SIGINT o SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Printf("escuchando en %s (versión %s)", addr, version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("error del servidor: %v", err)
}
}()
<-ctx.Done()
log.Println("señal recibida, cerrando conexiones")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("cierre forzado: %v", err)
}
log.Println("servidor detenido")
}
Los timeouts del http.Server no son opcionales en producción: sin ReadHeaderTimeout un cliente lento puede mantener conexiones abiertas indefinidamente. La aplicación escucha solo en 127.0.0.1, porque el tráfico público llegará a través de Nginx.
Paso 3: Compilar el binario de producción
Compila con la versión embebida. -trimpath elimina las rutas locales del binario y -ldflags="-s -w" quita la información de depuración para reducir el tamaño:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=1.0.0" -o goapp .
CGO_ENABLED=0 garantiza un binario totalmente estático, que funciona en cualquier distribución Linux sin depender de la versión de glibc. Compruébalo:
file goapp
./goapp -version
goapp: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, ..., stripped
1.0.0
Si compilas en otra máquina (un Mac o un runner de CI), indica el sistema y la arquitectura de destino y copia el binario al servidor:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=1.0.0" -o goapp .
scp goapp your_user@your_server_ip:~/goapp/
Usa GOARCH=arm64 si el servidor es ARM.
Paso 4: Crear el usuario y los directorios del servicio
La aplicación no debe ejecutarse como root ni poder modificar su propio binario. Crea un usuario de sistema sin shell ni directorio personal:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin goapp
Instala el binario en /opt/goapp, propiedad de root y solo ejecutable para el resto:
sudo install -d -m 0755 /opt/goapp
sudo install -m 0755 -o root -g root ~/goapp/goapp /opt/goapp/goapp
Guarda la configuración en un archivo de entorno legible solo por root y el grupo goapp, que es el lugar adecuado para cadenas de conexión y secretos:
sudo install -d -m 0750 -o root -g goapp /etc/goapp
sudo nano /etc/goapp/goapp.env
LISTEN_ADDR=127.0.0.1:8080
# DATABASE_URL=postgres://app:[email protected]:5432/app
sudo chown root:goapp /etc/goapp/goapp.env
sudo chmod 0640 /etc/goapp/goapp.env
Paso 5: Crear el servicio systemd
systemd arranca la aplicación al iniciar el servidor, la reinicia si falla, recoge sus logs en el journal y le envía SIGTERM al pararla. Crea la unidad:
sudo nano /etc/systemd/system/goapp.service
[Unit]
Description=goapp API HTTP en Go
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=goapp
Group=goapp
EnvironmentFile=/etc/goapp/goapp.env
ExecStart=/opt/goapp/goapp
Restart=on-failure
RestartSec=2
# Apagado ordenado: SIGTERM y hasta 30 s antes de SIGKILL
KillSignal=SIGTERM
TimeoutStopSec=30
# Endurecimiento
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
TimeoutStopSec=30 es mayor que los 20 segundos que la aplicación se concede para cerrar, de modo que systemd no la mata a mitad del apagado. ProtectSystem=strict monta todo el sistema de archivos en solo lectura para el proceso; si tu aplicación necesita escribir en algún directorio, añádelo con StateDirectory=goapp (crea /var/lib/goapp) o ReadWritePaths=.
Recarga systemd, activa el servicio y comprueba su estado:
sudo systemctl daemon-reload
sudo systemctl enable --now goapp
systemctl status goapp --no-pager
● goapp.service - goapp API HTTP en Go
Loaded: loaded (/etc/systemd/system/goapp.service; enabled; preset: enabled)
Active: active (running) since ...
Main PID: 4121 (goapp)
Prueba la aplicación en local:
curl -s http://127.0.0.1:8080/health
{"status":"ok","version":"1.0.0"}
Para ver los logs, incluido el apagado ordenado tras un reinicio:
sudo systemctl restart goapp
journalctl -u goapp -n 5 --no-pager
... goapp[4121]: señal recibida, cerrando conexiones
... goapp[4121]: servidor detenido
... systemd[1]: Stopped goapp.service - goapp API HTTP en Go.
... systemd[1]: Started goapp.service - goapp API HTTP en Go.
... goapp[4180]: escuchando en 127.0.0.1:8080 (versión 1.0.0)
Paso 6: Configurar Nginx como proxy inverso
Nginx se encarga del TLS, de los clientes lentos y de los logs de acceso, y reenvía las peticiones a la aplicación. Instálalo y abre el firewall:
sudo apt update
sudo apt install nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Crea el sitio:
sudo nano /etc/nginx/sites-available/goapp
upstream goapp {
server 127.0.0.1:8080;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name your_domain;
location / {
proxy_pass http://goapp;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location = /health {
proxy_pass http://goapp;
access_log off;
}
}
keepalive 16 junto con proxy_http_version 1.1 y la cabecera Connection vacía reutiliza las conexiones hacia Go en lugar de abrir una por petición. Activa el sitio, desactiva el sitio por defecto y valida la configuración:
sudo ln -s /etc/nginx/sites-available/goapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Comprueba que responde desde fuera:
curl -s http://your_domain/health
Paso 7: Activar HTTPS con Let's Encrypt
Instala Certbot con su plugin de Nginx y solicita el certificado. Certbot añade el bloque listen 443 ssl, las rutas del certificado y la redirección de HTTP a HTTPS en el archivo del sitio:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain --redirect
La renovación automática queda programada con un timer de systemd. Compruébala:
systemctl list-timers certbot.timer --no-pager
sudo certbot renew --dry-run
Y verifica el acceso por HTTPS:
curl -s https://your_domain/health
{"status":"ok","version":"1.0.0"}
Paso 8: Actualizar la aplicación y volver atrás
Como el binario es un único archivo, una actualización es: dejar la nueva versión junto a la actual, sustituirla con un mv (un renombrado atómico, el proceso en marcha no se ve afectado) y reiniciar. Compila la nueva versión en ~/goapp con -X main.version=1.1.0 y ejecuta:
sudo cp -p /opt/goapp/goapp /opt/goapp/goapp.prev
sudo install -m 0755 -o root -g root ~/goapp/goapp /opt/goapp/goapp.new
sudo mv /opt/goapp/goapp.new /opt/goapp/goapp
sudo systemctl restart goapp
curl -s http://127.0.0.1:8080/health
{"status":"ok","version":"1.1.0"}
Durante el reinicio, las peticiones en curso terminan gracias al apagado ordenado, pero hay una ventana de unos milisegundos en la que el puerto no acepta conexiones nuevas y Nginx devolvería un 502. Para eliminarla del todo necesitas dos instancias detrás del upstream y reiniciarlas de una en una.
Si la nueva versión falla, vuelve a la anterior:
sudo mv /opt/goapp/goapp.prev /opt/goapp/goapp
sudo systemctl restart goapp
Solución de problemas
El servicio no arranca con status=203/EXEC. systemd no puede ejecutar el binario: suele ser una arquitectura equivocada (binario arm64 en un servidor amd64) o falta el permiso de ejecución. Revisa con file /opt/goapp/goapp y ls -l /opt/goapp/goapp.
bind: address already in use. Otro proceso ocupa el puerto. Localízalo con:
sudo ss -ltnp 'sport = :8080'
Nginx devuelve 502 Bad Gateway. La aplicación no está escuchando en la dirección del upstream. Comprueba systemctl status goapp, el valor de LISTEN_ADDR y el log de errores con sudo tail -n 20 /var/log/nginx/error.log.
El servicio tarda 30 segundos en pararse. La aplicación no está gestionando SIGTERM y systemd acaba enviando SIGKILL. Verifica que usas signal.NotifyContext y srv.Shutdown como en el paso 2.
permission denied al escribir archivos. Es ProtectSystem=strict funcionando. Añade StateDirectory=goapp a la unidad y escribe en /var/lib/goapp.
Conclusión
Tienes una aplicación Go compilada como binario estático con su versión embebida, ejecutándose como servicio systemd endurecido con un usuario propio, publicada con HTTPS a través de Nginx y con un procedimiento de actualización y rollback de tres comandos. Como siguientes pasos, puedes automatizar la compilación y la copia del binario desde tu CI, exponer métricas en /metrics con la librería prometheus/client_golang o ejecutar dos instancias con una unidad plantilla de systemd ([email protected]) para actualizar sin ningún corte.
