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

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.