
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:distincluye también archivos que comienzan con.o_, que elgo:embedignora por defecto. Vite puede generar nombres así en algunos casos;fs.Subquita el prefijodist/. Sin él, elindex.htmlestaría endist/index.htmldentro 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: trueresuelve el problema de las URLs limpias del Vue Router, mencionado en la parte 2. Quien abre/tarefasdirectamente recibe elindex.html, y Vue Router toma el control desde ahí;- o
Skipperpara/api/evita el efecto secundario del modo HTML5. Sin él,/api/rota-erradatambién recibiría elindex.htmlcon estado 200, y el cliente de la API intentaría leer HTML como JSON. En nuestra prueba, sin elSkipperfue exactamente eso lo que ocurrió; con él, la respuesta es{"message":"Not Found"}con 404; echo.StartConfigconsignal.NotifyContextproporciona un apagado controlado. UnSIGTERMde 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=0genera 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;-trimpathquita 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.
embed.go(enweb/embed.go)main.goMakefileDockerfile.dockerignorecompose.yamlvueapp.service(en/etc/systemd/system/vueapp.service)
Recapitulando la serie
- Parte 1: entorno, Echo v5 y la primera ruta, con el
*echo.Contextde v5; - Parte 2: Vue 3 con Vite, Vue Router y el proxy de desarrollo;
- Parte 3: API REST con grupos,
Bind, validación yHTTPError; - Parte 4: login con bcrypt, cookie
HttpOnlyy 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