Vue.js + Go com Echo v5, parte 5: embed, binário único e 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

Série Vue.js + Go com Echo v5: Parte 1: ambiente e primeira API · Parte 2: frontend Vue com Vite · Parte 3: API REST de tarefas · Parte 4: login com sessão e cookie · Parte 5: embed, binário único e Docker

Código completo: todos os arquivos da série estão no gist vue-go-echo-v5, com um README que mostra em qual pasta cada um fica.

Chegamos à parte que faz essa dupla valer a pena. Até aqui a aplicação rodava como dois processos: o Vite servindo o Vue e o Go servindo a API. Nesta última parte o Vue compilado vai para dentro do binário Go com go:embed. O resultado é um único executável estático de cerca de 10 MB, que não depende de Node, de pasta de arquivos nem de servidor web para funcionar. Também escrevemos um Dockerfile que compila tudo sem Go ou Node instalados na máquina, e publicamos com systemd e Nginx.

Como o go:embed funciona

A diretiva //go:embed manda o compilador copiar arquivos para dentro do executável no momento do build. Eles ficam acessíveis como um embed.FS, que implementa a interface fs.FS, a mesma que o middleware de arquivos estáticos do Echo aceita. A documentação está em pkg.go.dev/embed.

Crie web/embed.go. Sim, um arquivo Go dentro da pasta do projeto Vue: o go:embed só enxerga arquivos no diretório do pacote e abaixo dele, então o pacote precisa morar ao lado do 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 inclui também arquivos que começam com . ou _, que o go:embed ignora por padrão. O Vite pode gerar nomes assim em alguns casos;
  • fs.Sub tira o prefixo dist/. Sem ele, o index.html estaria em dist/index.html dentro do FS.

Sirva o Vue pelo Echo

O main.go final registra a API e, depois dela, o middleware Static apontando para o FS embutido:

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
}

Três detalhes fazem a diferença:

  • HTML5: true resolve o problema das URLs limpas do Vue Router, citado na parte 2. Quem abre /tarefas direto recebe o index.html, e o Vue Router assume dali;
  • o Skipper para /api/ evita o efeito colateral do modo HTML5. Sem ele, /api/rota-errada também receberia o index.html com status 200, e o cliente da API tentaria ler HTML como JSON. No nosso teste, sem o Skipper foi exatamente isso que aconteceu; com ele, a resposta é {"message":"Not Found"} com 404;
  • echo.StartConfig com signal.NotifyContext dá desligamento gracioso. Um SIGTERM do systemd ou do Docker espera as requisições em andamento terminarem, por até 10 segundos por padrão, antes de encerrar.

Build do binário único

A ordem importa: primeiro o frontend, depois o Go. O go:embed precisa encontrar o web/dist na hora de compilar. Num clone novo, em que o dist não existe porque está no .gitignore, o go build falha:

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

Para não depender de memória, deixe a ordem num Makefile na raiz. Lembre que as linhas de comando começam com 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 gera um binário estático, que roda em qualquer Linux da mesma arquitetura sem depender da glibc;
  • -ldflags "-s -w" remove símbolos de depuração e reduz o tamanho;
  • -trimpath tira os caminhos da sua máquina de dentro do executável.

Para provar que não há dependência de arquivos externos, copie o binário para outra pasta e rode:

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

Abra http://127.0.0.1:8080: login, tarefas e navegação funcionando, com uma porta e um processo só. Para outra arquitetura, basta GOARCH=arm64 no go build, porque o frontend compilado é o mesmo.

Build com Docker, sem instalar Go nem Node

Um Dockerfile multi-stage resolve o build em qualquer máquina que tenha Docker, com versões fixas de Node e Go, e ainda gera uma imagem mínima. Crie o Dockerfile na raiz:

# 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"]

E um .dockerignore, para não mandar ao build o node_modules e artefatos locais:

.git
bin
web/node_modules
web/dist

Os estágios ajudam o cache: package.json e go.mod são copiados antes do código, então as dependências só são baixadas de novo quando mudam.

Opção 1: só o binário

O estágio bin existe para uma coisa: extrair o executável para a sua máquina com o exportador local do BuildKit:

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

Você recebe o mesmo binário estático do make build, sem ter Go ou Node instalados. É a forma mais simples de compilar numa máquina limpa ou num pipeline de CI.

Opção 2: a imagem

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

A imagem parte do scratch, vazio. Ela contém só o binário: sem shell, sem gerenciador de pacotes, sem nada para um invasor aproveitar. Roda como o usuário 65534 (nobody), e não como root. Dentro do contêiner, o APP_ADDR é 0.0.0.0:8080, porque 127.0.0.1 ali dentro não seria alcançável pelo mapeamento de porta; do lado do host, o -p 127.0.0.1:8080:8080 continua expondo só localmente.

Opção 3: Docker Compose

Para subir com um comando, crie 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

A sintaxe ${APP_PASSWORD:?...} faz o Compose recusar a subida se a senha não estiver definida, em vez de iniciar com um valor vazio:

required variable APP_PASSWORD is missing a value: defina APP_PASSWORD

Para aprender o básico de contêineres, veja o nosso curso de Docker.

Sem Docker: systemd

Se preferir o binário direto no servidor, um serviço systemd com usuário próprio e sistema de arquivos somente leitura resolve. Veja também o guia de comandos essenciais do 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 cria um usuário temporário para o serviço, e o EnvironmentFile com permissão 0600 mantém a senha fora da unit e do ps. O SIGTERM do systemctl stop aciona o desligamento gracioso.

HTTPS com Nginx na frente

O cookie de sessão não pode trafegar em HTTP fora do localhost. Coloque o Nginx na frente, com o certificado, e repasse X-Forwarded-Proto: é esse cabeçalho que liga o Secure do cookie, como vimos na 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;
    }
}

Só aceite X-Forwarded-Proto quando o Go estiver escutando em 127.0.0.1, atrás do proxy. Se a porta estivesse exposta, qualquer cliente poderia enviar esse cabeçalho.

Arquivos desta parte no gist

Cada arquivo abre direto no gist da série. O gist traz a versão final do projeto: main.go e routes.go ainda ganham login na parte 4 e o frontend embutido na parte 5.

Recapitulando a série

  • Parte 1: ambiente, Echo v5 e a primeira rota, com o *echo.Context da v5;
  • Parte 2: Vue 3 com Vite, Vue Router e o proxy de desenvolvimento;
  • Parte 3: API REST com grupos, Bind, validação e HTTPError;
  • Parte 4: login com bcrypt, cookie HttpOnly e middleware de autenticação;
  • Parte 5: go:embed, binário único de cerca de 10 MB, Docker, systemd e Nginx.

Daqui, os próximos passos naturais são trocar o armazenamento em memória por SQLite ou PostgreSQL, guardar as sessões no banco e adicionar testes com o pacote echotest do próprio Echo. A série phpVirtualBox em Go mostra essa mesma arquitetura aplicada a um projeto real.

Série Vue.js + Go com Echo v5: Parte 1: ambiente e primeira API · Parte 2: frontend Vue com Vite · Parte 3: API REST de tarefas · Parte 4: login com sessão e cookie · Parte 5: embed, binário único e Docker