← Blog

Desplegar una aplicación web Go en Hetzner

Railyard

22 de agosto de 2026 · 14 min de lectura

Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta

Un binario de Go es inusualmente fácil de poner en un servidor: no hay un intérprete que instalar, ningún gestor de paquetes que ejecutar, ninguna versión de runtime que fijar en el host. Se compila una vez, se copia, y funciona. Esa simplicidad no elimina el resto del trabajo. Igual se necesita un servidor, un dominio apuntando a él, una base de datos, HTTPS delante de todo, un respaldo que sobreviva a la desaparición del servidor, y una manera corta de enviar el siguiente cambio. Esta guía construye todo eso a mano y luego muestra el atajo.

La aplicación en sí es deliberadamente pequeña: un servicio net/http que usa solo la biblioteca estándar, con una ruta que lee y escribe en Postgres a través de pgx/v5, para que haya una razón real de hablar de una conexión a base de datos y no solo un “hello world” estático.

Lo que necesitas

  • Una cuenta de Hetzner Cloud y un servidor. El CX23 de costo optimizado (2 vCPU, 4 GB de memoria, 40 GB NVMe) se publica a 5,99 €/mes sin IVA. Verifica los precios y la disponibilidad actuales en la consola de Hetzner Cloud antes de pedirlo, ya que las gamas y los precios cambian.
  • Un nombre de dominio al que puedas agregar un registro DNS.
  • Un par de llaves SSH en tu máquina (ssh-keygen -t ed25519 si aún no tienes una).
  • Go 1.27 o más reciente instalado localmente, para escribir y construir la aplicación antes de que toque el servidor.
  • Unos quince minutos para que el servidor y el DNS se estabilicen, sobre todo tiempo de espera, no de escritura.

Paso 1: crear el servidor y apuntar un dominio a él

En la consola de Hetzner Cloud, abre (o crea) un proyecto, luego elige Add Server. Elige una ubicación: Núremberg, Falkenstein o Helsinki mantienen tus datos en la UE, mientras que Ashburn, Hillsboro o Singapur también están disponibles si tus usuarios están en otra parte. Elige Ubuntu 24.04 como imagen, luego en tipo de servidor elige el vCPU compartido CX23 (2 vCPU, 4 GB de memoria, 40 GB NVMe). En llaves SSH, agrega la llave pública de tu máquina en lugar de depender de una contraseña generada, luego crea el servidor.

Una vez que arranque, la consola muestra su dirección IPv4 pública. Conéctate para confirmar el acceso:

ssh root@YOUR_SERVER_IP

Deberías ver el banner de bienvenida de Ubuntu y llegar a una shell de root. Si la conexión es rechazada, verifica que la llave SSH asociada al servidor coincida con la llave privada en tu máquina.

Agrega un registro A en tu proveedor de DNS para el nombre de host que quieras usar, apuntando a esa IP:

Type: A
Name: myapp (o @ para el dominio desnudo)
Value: YOUR_SERVER_IP
TTL: 300

Verifica que resuelva antes de continuar, ya que Caddy lo necesitará funcionando en un paso posterior:

dig +short myapp.example.com

Deberías ver la IP de tu servidor impresa de vuelta. Si no obtienes nada, espera unos minutos y vuelve a intentarlo.

Hetzner no pone un firewall delante de un servidor nuevo por defecto, así que asegura el host solo con lo que necesitas usando ufw:

ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable

Paso 2: instalar Docker en el servidor

El propio script de instalación de Docker se encarga tanto del motor como del plugin de Compose en un Ubuntu recién instalado:

apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker

Verifica la instalación:

docker --version
docker compose version

Deberías ver una versión de Docker y una versión de Compose impresas, no un error de “command not found”.

Paso 3: escribir la aplicación Go

En tu propia máquina, configura el módulo:

mkdir myapp && cd myapp
go mod init myapp
go get github.com/jackc/pgx/v5

go mod init y go get escriben un go.mod como este (tus versiones exactas de dependencias pueden variar un poco según cuándo lo ejecutes, ya que go mod tidy las resuelve en el momento de la solicitud):

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
)

Ahora main.go, que usa solo net/http de la biblioteca estándar más pgxpool para la base de datos:

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})
}

Dos cosas importan aquí más allá de los handlers mismos. Primero, la aplicación lee PORT del entorno y recurre a 8080 por defecto, y se enlaza a 0.0.0.0:$PORT, no a localhost:$PORT. Caddy corre en su propio contenedor y alcanza esta aplicación a través de la red de Docker por el nombre del contenedor, no por la interfaz de loopback, así que enlazarse a localhost haría que Caddy nunca pudiera alcanzarla. Segundo, DATABASE_URL se lee del entorno en lugar de estar codificado, de modo que el mismo binario exacto corre sin cambios contra una base de datos local, una de staging o la de producción en este servidor.

Vale la pena nombrar lo que falta comparado con un lenguaje interpretado. Un despliegue de Rails o Django tiene que asegurarse de que la versión correcta de Ruby o Python esté instalada en el servidor, y que las gemas o paquetes correspondientes se obtengan y se encuentren en las rutas que el intérprete espera. Nada de eso aplica aquí. go build produce un solo binario con todo ya enlazado adentro; el servidor no necesita Go instalado en absoluto, solo el archivo compilado. Por eso también el Dockerfile del siguiente paso tiene dos etapas: una para compilar, otra para simplemente ejecutar el resultado.

Paso 4: escribir el 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"]

La primera etapa usa golang:1.27.3-alpine para compilar el binario. CGO_ENABLED=0 desactiva cgo, lo que significa que el binario resultante no se enlaza en absoluto con la biblioteca C del sistema, y GOOS=linux garantiza un binario Linux incluso si estás construyendo en una Mac. La segunda etapa parte de un simple alpine:3.21, mucho más pequeño que la imagen de construcción, y copia únicamente el binario server compilado. Se instala ca-certificates para que el binario pueda verificar certificados TLS si DATABASE_URL alguna vez apunta a una instancia de Postgres que requiera SSL; pgx no tiene dependencias de C una vez fijado CGO_ENABLED=0, así que no se necesita instalar nada más en esta etapa de ejecución.

Paso 5: escribir el archivo Docker Compose

Copia tu aplicación al servidor (scp, rsync, o un git clone de tu repositorio en /opt/myapp), crea un archivo .env con una contraseña de base de datos, luego agrega compose.yml junto a él:

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:

El servicio app alcanza Postgres en el nombre de host db, el nombre que Compose le da a ese servicio, no localhost. depends_on: condition: service_healthy evita que la aplicación arranque antes de que Postgres pueda realmente aceptar conexiones, no solo antes de que su contenedor exista. Solo caddy publica puertos hacia el host, así que Postgres y la propia aplicación nunca son alcanzables desde fuera de este servidor.

Verifica que el archivo se analice correctamente antes de continuar:

docker compose config

Deberías ver la configuración completamente resuelta impresa sin líneas de error.

Paso 6: escribir el Caddyfile

myapp.example.com {
	reverse_proxy app:8080
}

Reemplaza myapp.example.com con el dominio que apuntaste al servidor en el Paso 1, y 8080 con el PORT que hayas fijado en compose.yml si lo cambias. Caddy reenvía cada solicitud al contenedor app en ese puerto, y solicita un certificado TLS gratuito de Let’s Encrypt para ese nombre de host exacto, renovándolo automáticamente. Esto solo funciona si el dominio ya resuelve a este servidor, por lo que el DNS vino antes que Caddy en esta guía. Puedes verificar que el Caddyfile esté bien formado con:

docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile

Deberías ver Valid configuration en la salida.

Paso 7: levantar la pila

Desde /opt/myapp:

docker compose up -d --build

Esto construye la imagen, arranca Postgres, espera a que reporte estar saludable, y luego arranca la aplicación, que se conecta a Postgres y crea su única tabla en el primer arranque.

Verifica la pila:

docker compose ps
curl -I https://myapp.example.com/healthz

Deberías ver los tres servicios como Up (y db como healthy), y el curl debería devolver HTTP/2 200 sin advertencia de certificado. Golpear /visits varias veces debería devolver un conteo creciente:

curl https://myapp.example.com/visits

Paso 8: respaldar Postgres

Un dump tomado fuera del volumen de datos es lo que realmente te protege si el servidor desaparece. Agrega un script de respaldo:

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

Prográmalo con cron, diariamente a las 3am:

(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -

Ejecútalo una vez a mano y verifica que funcionó:

/opt/myapp/backup.sh
ls -lh /opt/myapp/backups

Deberías ver un archivo .sql.gz con la marca de tiempo de hoy y un tamaño distinto de cero. Para cualquier cosa más allá de un proyecto personal, copia estos archivos también fuera del servidor, a otra máquina o un almacenamiento de objetos, para que perder el servidor no signifique también perder el respaldo.

Paso 9: enviar una actualización

Cada cambio posterior sigue la misma forma: llevar el código nuevo al servidor, reconstruir la imagen, reemplazar el contenedor en ejecución.

git pull
docker compose build app
docker compose up -d app

docker compose up -d app solo recrea el contenedor app, no db ni caddy, así que Postgres sigue corriendo y el estado del certificado de Caddy queda intacto. Hay una breve pausa, un segundo o dos, mientras el contenedor anterior se detiene y el nuevo arranca, ya que esta configuración corre un solo contenedor de aplicación.

Vale la pena ser explícito sobre algo que Rails y Django manejan automáticamente y Go no: no hay una convención dominante única aquí para ejecutar migraciones de esquema como parte de un despliegue. La única tabla de esta guía se crea con CREATE TABLE IF NOT EXISTS directamente en main.go, lo cual está bien para algo tan pequeño, pero una aplicación real con un esquema que evoluciona suele recurrir a una herramienta dedicada. goose y golang-migrate son las dos más comunes, ejecutadas como un paso explícito, ya sea integrado en el comando de arranque del contenedor o ejecutado a mano antes de docker compose up -d app. Elige una y sé consistente, ya que nada lo hará implícitamente por ti.

Verifica que haya quedado bien:

docker compose logs --tail=50 app
curl -I https://myapp.example.com/healthz

Deberías ver tu aplicación arrancar limpiamente en los logs y el curl seguir devolviendo 200 en la nueva versión.

El atajo: hacerlo con Railyard

Todo lo anterior (el servidor, Docker, Postgres, Caddy, los respaldos, un lanzamiento repetible) es también lo que Railyard automatiza para una aplicación Go en Hetzner, sin escribir ninguno de los archivos anteriores a mano.

Railyard detecta Go a partir de go.mod, incluyendo un módulo que vive en una subcarpeta en lugar de la raíz del repositorio, y lo construye desde el lugar correcto automáticamente. Un import de controlador de base de datos conocido en tu código, github.com/jackc/pgx, github.com/lib/pq, github.com/go-sql-driver/mysql o github.com/redis/go-redis, aprovisiona la base de datos administrada correspondiente por ti e inyecta DATABASE_URL o REDIS_URL como variable de entorno, sin ningún addon que agregar a mano, la misma conexión que esta guía conectó manualmente en el Paso 5. El único requisito se mantiene igual que en el Dockerfile manual anterior: el binario tiene que escuchar en $PORT, en todas las interfaces, no solo en localhost.

Go no tiene un framework de colas de trabajo dominante como Rails tiene Sidekiq o Solid Queue, así que tampoco hay nada que Railyard pueda autodetectar ahí. En su lugar, un proceso worker es una sola línea en una declaración de procesos estilo Procfile:

web:    ./server
worker: ./server -mode=worker

Ambas líneas ejecutan el mismo binario construido; tu propio main() lee una bandera o variable de entorno para decidir qué bucle ejecutar. Cada tipo de proceso escala independientemente de los demás.

Pega la URL de tu repositorio en https://app.railyard.run/deploy?repo= y elige Hetzner como proveedor, o ve directamente por la ruta de despliegue de Go en Hetzner. Consulta la documentación de Go para las reglas de detección exactas. El plan gratuito de Railyard cubre un servidor más un servidor de base de datos y tres aplicaciones sin tarjeta requerida, Pro es $19/mes por equipo, y Team es $49/mes por equipo.

Solución de problemas

El binario construido localmente no corre en el servidor. Un error como exec format error al arrancar el contenedor usualmente significa que el binario se compiló de forma cruzada para la arquitectura equivocada, por ejemplo construido en una Mac con Apple Silicon sin GOARCH=amd64 y luego copiado directamente a un servidor x86, o que GOOS=linux se omitió, así que el binario solo corre en la máquina que lo construyó. Construir dentro del Dockerfile con CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build, como se muestra en el Paso 4, en lugar de copiar un binario construido localmente, evita esto porque la construcción siempre apunta a la plataforma correcta.

Conexión a Postgres rechazada. Si los logs de la aplicación muestran could not connect to server: Connection refused o algo similar, verifica que DATABASE_URL use db como nombre de host, no localhost ni 127.0.0.1. Dentro de la red de Compose, db es la única dirección que resuelve al contenedor de Postgres; localhost significa el propio contenedor app, que no tiene ninguna base de datos corriendo dentro.

Puerto ya en uso. Si docker compose up falla porque algo ya se enlaza al puerto 80 o 443, el Apache o Nginx por defecto de Ubuntu puede estar ya instalado y corriendo. Verifica con ss -tlnp | grep -E ':80|:443' y detén o elimina el servicio que lo ocupe antes de que Caddy intente enlazarse.

El HTTPS no se emite. Si docker compose logs caddy muestra errores de certificado repetidos o NXDOMAIN, tu registro DNS del Paso 1 aún no se ha propagado o apunta a la IP equivocada. Vuelve a ejecutar dig +short myapp.example.com, confirma que coincide con el servidor, luego reinicia Caddy con docker compose restart caddy una vez que lo haga.

Falta de memoria bajo carga. Los 4 GB de memoria de un CX23 corren esta pila cómodamente en reposo, pero una fuga de goroutines, por ejemplo lanzar una por solicitud y nunca dejarla terminar, puede hacer crecer el uso de memoria hasta que el kernel empiece a matar procesos. docker stats en el servidor muestra si la memoria del contenedor app está subiendo con el tiempo en lugar de estabilizarse tras una ráfaga de tráfico; el perfil de heap de pprof es el siguiente paso si es así.

Preguntas frecuentes

¿Necesito un framework como gin o echo para una aplicación pequeña? No. net/http y http.ServeMux de la biblioteca estándar manejan el enrutamiento, y los patrones de http.Handler y middleware son Go puro, sin framework requerido, para cualquier cosa que no implique decenas de rutas o querer validación y agrupación de solicitudes integradas.

¿Cómo agrego un proceso worker? Agrega un segundo servicio a compose.yml construido desde la misma imagen, ejecutando el mismo binario con una bandera o comando diferente, por ejemplo ./server -mode=worker en lugar de ./server. Lee el mismo DATABASE_URL, solo que sin enlazarse a ningún puerto.

¿El enfoque de binario estático realmente importa para la seguridad? Elimina toda una categoría de problema: ninguna versión de intérprete con sus propios CVE que parchar en el servidor, y ningún gestor de paquetes resolviendo dependencias transitivas en el momento del despliegue. Las vulnerabilidades en tus propias dependencias de Go todavía necesitan seguimiento, igual que en cualquier lenguaje, pero la superficie de ataque de “qué está instalado además de mi aplicación” es cercana a cero.

¿Qué pasa si la aplicación no tiene ninguna base de datos? Quita por completo el servicio db de compose.yml, quita DATABASE_URL del entorno de app, y el resto de la guía no cambia: construye el binario de la misma manera, ejecútalo detrás de Caddy de la misma manera, no respaldes nada porque no hay nada con estado que respaldar.

¿Cómo funcionan las migraciones, ya que Go no tiene una convención única? A diferencia del db:prepare de Rails o el migrate de Django, nada se ejecuta implícitamente. Elige una herramienta, goose y golang-migrate son las dos más usadas, y ejecútala como un paso explícito en tu proceso de despliegue, ya sea como comando en el punto de entrada del contenedor o como paso manual antes de docker compose up -d app. Para un esquema tan simple, CREATE TABLE IF NOT EXISTS al arrancar, como hace el main.go de esta guía, es un parche razonable, pero no maneja alterar una tabla existente, que es donde una herramienta de migración real demuestra su valor.

Esa es una configuración de producción completa para un servicio Go: un servidor, Docker Compose corriendo la aplicación y Postgres, Caddy manejando HTTPS, un respaldo nocturno, y un lanzamiento de dos comandos. Mantén compose.yml, Caddyfile y el Dockerfile en tu repositorio junto a la aplicación, y cada futuro despliegue es el mismo git pull, construcción, reinicio que acabas de ejecutar en el Paso 9.

Por qué eligen Railyard para esto

  • Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
  • Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
  • Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
  • Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
  • Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.

Sáltate la configuración del servidor

Pega tu repositorio Go en Railyard y obtén una base de datos administrada, HTTPS y respaldos configurados en Hetzner automáticamente.

Plan gratis, sin tarjeta, y 14 días de Pro para cada equipo nuevo. Un precio fijo por equipo.