
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:distinclui também arquivos que começam com.ou_, que ogo:embedignora por padrão. O Vite pode gerar nomes assim em alguns casos;fs.Subtira o prefixodist/. Sem ele, oindex.htmlestaria emdist/index.htmldentro 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: trueresolve o problema das URLs limpas do Vue Router, citado na parte 2. Quem abre/tarefasdireto recebe oindex.html, e o Vue Router assume dali;- o
Skipperpara/api/evita o efeito colateral do modo HTML5. Sem ele,/api/rota-erradatambém receberia oindex.htmlcom status 200, e o cliente da API tentaria ler HTML como JSON. No nosso teste, sem oSkipperfoi exatamente isso que aconteceu; com ele, a resposta é{"message":"Not Found"}com 404; echo.StartConfigcomsignal.NotifyContextdá desligamento gracioso. UmSIGTERMdo 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=0gera 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;-trimpathtira 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.
embed.go(emweb/embed.go)main.goMakefileDockerfile.dockerignorecompose.yamlvueapp.service(em/etc/systemd/system/vueapp.service)
Recapitulando a série
- Parte 1: ambiente, Echo v5 e a primeira rota, com o
*echo.Contextda v5; - Parte 2: Vue 3 com Vite, Vue Router e o proxy de desenvolvimento;
- Parte 3: API REST com grupos,
Bind, validação eHTTPError; - Parte 4: login com bcrypt, cookie
HttpOnlye 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