Vue.js + Go con Echo v5, parte 5: embed, binario único y Docker

Mascote LinuxPro empacotando um cubo com o logo do Vue numa caixa com o gopher do Go, com a baleia do Docker ao fundo e o cachorro caramelo cyborg brincando

Serie Vue.js + Go con Echo v5: Parte 1: entorno y primera API · Parte 2: frontend Vue con Vite · Parte 3: API REST de tareas · Parte 4: inicio de sesión con sesión y cookie · Parte 5: embed, binario único y Docker

Código completo: todos los archivos de la serie están en el gist vue-go-echo-v5, con un README que muestra en qué carpeta se encuentra cada uno.

Llegamos a la parte que hace que esta pareja merezca la pena. Hasta ahora la aplicación se ejecutaba como dos procesos: Vite sirviendo Vue y Go sirviendo la API. En esta última parte el Vue compilado va dentro del binario Go con go:embed. El resultado es un único ejecutable estático de unos 10 MB, que no depende de Node, de una carpeta de archivos ni de un servidor web para funcionar. También escribimos un Dockerfile que compila todo sin Go ni Node instalados en la máquina, y lo publicamos con systemd y Nginx.

Cómo funciona go:embed

La directiva //go:embed indica al compilador que copie archivos dentro del ejecutable en el momento del build. Quedan accesibles como un embed.FS, que implementa la interfaz fs.FS, la misma que el middleware de archivos estáticos de Echo acepta. La documentación está en pkg.go.dev/embed.

Cree web/embed.go. Sí, un archivo Go dentro de la carpeta del proyecto Vue: el go:embed solo ve archivos en el directorio del paquete y por debajo, así que el paquete necesita estar junto al dist:

// Package web entrega o frontend Vue compilado, embutido no binário.
package web

import (
	"embed"
	"io/fs"
)

//go:embed all:dist
var dist embed.FS

// Dist devolve o conteúdo de web/dist como raiz do sistema de arquivos.
func Dist() fs.FS {
	sub, err := fs.Sub(dist, "dist")
	if err != nil {
		panic(err)
	}
	return sub
}
  • all:dist incluye también archivos que comienzan con . o _, que el go:embed ignora por defecto. Vite puede generar nombres así en algunos casos;
  • fs.Sub quita el prefijo dist/. Sin él, el index.html estaría en dist/index.html dentro del FS.

Servir Vue con Echo

O main.go final registra la API y, después de ella, el middleware Static apuntando al FS integrado:

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"strings"
	"syscall"

	"github.com/exemplo/vueapp/internal/api"
	"github.com/exemplo/vueapp/web"
	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
)

func main() {
	user := getenv("APP_USER", "admin")
	pass := os.Getenv("APP_PASSWORD")
	if pass == "" {
		slog.Error("defina APP_PASSWORD")
		os.Exit(1)
	}
	auth, err := api.NewAuth(user, pass)
	if err != nil {
		slog.Error("hash da senha", "error", err)
		os.Exit(1)
	}

	e := echo.New()
	e.Use(middleware.RequestLogger())
	e.Use(middleware.Recover())

	api.Register(e, auth, api.NewTasks())

	// Frontend Vue embutido: arquivos de web/dist e index.html para as rotas do Vue Router.
	e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
		Filesystem: web.Dist(),
		HTML5:      true,
		// /api/* nunca cai no index.html: rota inexistente da API devolve 404 em JSON
		Skipper: func(c *echo.Context) bool {
			return strings.HasPrefix(c.Request().URL.Path, "/api/")
		},
	}))

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	sc := echo.StartConfig{Address: getenv("APP_ADDR", "127.0.0.1:8080")}
	if err := sc.Start(ctx, e); err != nil {
		slog.Error("servidor", "error", err)
		os.Exit(1)
	}
}

func getenv(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

Tres detalles marcan la diferencia:

  • HTML5: true resuelve el problema de las URLs limpias del Vue Router, mencionado en la parte 2. Quien abre /tarefas directamente recibe el index.html, y Vue Router toma el control desde ahí;
  • o Skipper para /api/ evita el efecto secundario del modo HTML5. Sin él, /api/rota-errada también recibiría el index.html con estado 200, y el cliente de la API intentaría leer HTML como JSON. En nuestra prueba, sin el Skipper fue exactamente eso lo que ocurrió; con él, la respuesta es {"message":"Not Found"} con 404;
  • echo.StartConfig con signal.NotifyContext proporciona un apagado controlado. Un SIGTERM de systemd o de Docker espera a que las solicitudes en curso terminen, hasta 10 segundos por defecto, antes de finalizar.

Compilación del binario único

El orden importa: primero el frontend, luego el Go. El go:embed necesita encontrar el web/dist al compilar. En un clon nuevo, donde el dist no existe porque está en el .gitignore, el go build error:

web/embed.go:9:12: pattern all:dist: no matching files found

Para no depender de la memoria, deje el orden num Makefile en la raíz. Recuerde que las líneas de comando comienzan con TAB:

build:
	cd web && npm ci && npm run build
	CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o bin/vueapp .

dev-api:
	APP_PASSWORD=dev go run .

dev-web:
	cd web && npm run dev

clean:
	rm -rf bin web/dist
make build
ls -lh bin/vueapp
file bin/vueapp
-rwxr-xr-x 1 user user 9,5M bin/vueapp
bin/vueapp: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, ... stripped
  • CGO_ENABLED=0 genera un binario estático, que se ejecuta en cualquier Linux de la misma arquitectura sin depender de glibc;
  • -ldflags "-s -w" elimina símbolos de depuración y reduce el tamaño;
  • -trimpath quita las rutas de tu máquina del ejecutable.

Para demostrar que no hay dependencia de archivos externos, copia el binario a otra carpeta y ejecútalo:

cp bin/vueapp /tmp/ && cd /tmp
APP_PASSWORD=segredo123 ./vueapp

Abre http://127.0.0.1:8080: login, tareas y navegación funcionando, con un solo puerto y un solo proceso. Para otra arquitectura, basta GOARCH=arm64 no go build, porque el frontend compilado es el mismo.

Compilación con Docker, sin instalar Go ni Node

Un Dockerfile multi-stage resuelve la compilación en cualquier máquina que tenga Docker, con versiones fijas de Node y Go, y además genera una imagen mínima. Crea el Dockerfile en la raíz:

# 1) Frontend: compila o Vue com Node
FROM node:24-alpine AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build

# 2) Backend: compila o Go já com o web/dist embutido
FROM golang:1.27.1-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web /src/web/dist ./web/dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/vueapp .

# 3) Só o binário, para extrair com --output
FROM scratch AS bin
COPY --from=build /out/vueapp /vueapp

# 4) Imagem final: nada além do binário, rodando sem root
FROM scratch
COPY --from=build /out/vueapp /vueapp
USER 65534:65534
ENV APP_ADDR=0.0.0.0:8080
EXPOSE 8080
ENTRYPOINT ["/vueapp"]

Y un .dockerignore, para no enviar al build el node_modules y artefactos locales:

.git
bin
web/node_modules
web/dist

Las etapas ayudan al caché: package.json e go.mod se copian antes que el código, por lo que las dependencias solo se vuelven a descargar cuando cambian.

Opción 1: solo el binario

La etapa bin existe para una sola cosa: extraer el ejecutable a tu máquina con el exportador local de BuildKit:

docker build --target bin --output type=local,dest=bin .
ls -lh bin/vueapp

Recibes el mismo binario estático de make build, sin tener Go o Node instalados. Es la forma más sencilla de compilar en una máquina limpia o en un pipeline de CI.

Opción 2: la imagen

docker build -t vueapp .
docker images vueapp
docker run -d --name vueapp -p 127.0.0.1:8080:8080 -e APP_PASSWORD=segredo123 vueapp
REPOSITORY   TAG       SIZE
vueapp       latest    9.9MB

La imagen parte del scratch, vacío. Contiene solo el binario: sin shell, sin gestor de paquetes, sin nada que un atacante pueda aprovechar. Se ejecuta como el usuario 65534 (nobody), y no como root. Dentro del contenedor, el APP_ADDR é 0.0.0.0:8080, porque 127.0.0.1 dentro no sería accesible mediante el mapeo de puertos; en el lado del host, el -p 127.0.0.1:8080:8080 sigue exponiéndose solo localmente.

Opción 3: Docker Compose

Para levantarlo con un solo comando, crea compose.yaml:

services:
  app:
    build: .
    image: vueapp:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      APP_USER: admin
      APP_PASSWORD: ${APP_PASSWORD:?defina APP_PASSWORD}
APP_PASSWORD='troque-esta-senha' docker compose up -d --build

La sintaxis ${APP_PASSWORD:?...} hace que Compose rechace el arranque si la contraseña no está definida, en lugar de iniciarse con un valor vacío:

required variable APP_PASSWORD is missing a value: defina APP_PASSWORD

Para aprender lo básico sobre contenedores, consulta nuestro curso de Docker.

Sin Docker: systemd

Si prefieres el binario directamente en el servidor, un servicio systemd con su propio usuario y un sistema de archivos de solo lectura lo resuelve. Consulta también la guía de comandos esenciales de systemd.

sudo install -m 0755 bin/vueapp /usr/local/bin/vueapp
sudo install -d -m 0750 /etc/vueapp
echo 'APP_PASSWORD=troque-esta-senha' | sudo tee /etc/vueapp/env >/dev/null
sudo chmod 0600 /etc/vueapp/env

/etc/systemd/system/vueapp.service:

[Unit]
Description=Vue + Go (Echo v5)
After=network-online.target
Wants=network-online.target

[Service]
EnvironmentFile=/etc/vueapp/env
Environment=APP_ADDR=127.0.0.1:8080
ExecStart=/usr/local/bin/vueapp
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now vueapp
curl -s http://127.0.0.1:8080/api/health
journalctl -u vueapp -f

O DynamicUser=yes crea un usuario temporal para el servicio, y el EnvironmentFile con permiso 0600 mantiene la contraseña fuera de la unit y del ps. El SIGTERM del systemctl stop activa el apagado ordenado.

HTTPS con Nginx delante

La cookie de sesión no puede viajar en HTTP fuera del localhost. Coloque Nginx delante, con el certificado, y reenvíe X-Forwarded-Proto: es ese encabezado el que conecta el Secure de la cookie, como vimos en la parte 4.

server {
    listen 443 ssl;
    server_name app.exemplo.com;

    ssl_certificate     /etc/letsencrypt/live/app.exemplo.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.exemplo.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Solo acepte X-Forwarded-Proto cuando Go esté escuchando en 127.0.0.1, detrás del proxy. Si el puerto estuviera expuesto, cualquier cliente podría enviar ese encabezado.

Archivos de esta parte en el gist

Cada archivo abre directamente en el gist de la serie. El gist trae la versión final del proyecto: main.go e routes.go también obtienen login en la parte 4 y el frontend integrado en la parte 5.

Recapitulando la serie

  • Parte 1: entorno, Echo v5 y la primera ruta, con el *echo.Context de v5;
  • Parte 2: Vue 3 con Vite, Vue Router y el proxy de desarrollo;
  • Parte 3: API REST con grupos, Bind, validación y HTTPError;
  • Parte 4: login con bcrypt, cookie HttpOnly y middleware de autenticación;
  • Parte 5: go:embed, binario único de unos 10 MB, Docker, systemd y Nginx.

A partir de aquí, los próximos pasos naturales son cambiar el almacenamiento en memoria por SQLite o PostgreSQL, guardar las sesiones en la base de datos y añadir pruebas con el paquete echotest propio de Echo. La serie phpVirtualBox en Go muestra esta misma arquitectura aplicada a un proyecto real.

Serie Vue.js + Go con Echo v5: Parte 1: entorno y primera API · Parte 2: frontend Vue con Vite · Parte 3: API REST de tareas · Parte 4: inicio de sesión con sesión y cookie · Parte 5: embed, binario único y Docker