Vue.js + Go with Echo v5, part 5: embed, single binary, and 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

Vue.js + Go with Echo v5 series: Part 1: environment and first API · Part 2: Vue frontend with Vite · Part 3: tasks REST API · Part 4: login with session and cookie · Part 5: embed, single binary and Docker

Complete code: all files from the series are in the gist vue-go-echo-v5, with a README that shows which folder each one goes in.

We have reached the part that makes this duo worthwhile. Until now, the application ran as two processes: Vite serving Vue and Go serving the API. In this final part, the compiled Vue goes into the Go binary with go:embed. The result is a single static executable of about 10 MB, that does not depend on Node, an assets folder, or a web server to work. We also wrote a Dockerfile that builds everything without Go or Node installed on the machine, and we deploy it with systemd and Nginx.

How go:embed works

The directive //go:embed tells the compiler to copy files into the executable at build time. They become accessible as an embed.FS, which implements the fs.FS, interface — the same one Echo's static files middleware accepts. The documentation is at pkg.go.dev/embed.

Create web/embed.go. Yes, a Go file inside the Vue project's folder: go:embed only sees files in the package directory and below it, so the package needs to live next to the 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 also includes files starting with . or _, which go:embed ignores by default. Vite can generate names like this in some cases;
  • fs.Sub strips the prefix dist/. Without it, the index.html would be in dist/index.html inside the FS.

Serve Vue through Echo

The main.go finally registers the API and, after it, the middleware Static pointing to the embedded FS:

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
}

Three details make the difference:

  • HTML5: true solves the clean URLs issue from Vue Router, mentioned in part 2. Anyone who goes to /tarefas directly receives the index.html, and Vue Router takes over from there;
  • o Skipper to /api/ avoids the side effect of HTML5 mode. Without it, /api/rota-errada would also receive the index.html with 200 status, and the API client would try to read HTML as JSON. In our test, without the Skipper that's exactly what happened; with it, the response is {"message":"Not Found"} with 404;
  • echo.StartConfig with signal.NotifyContext provides graceful shutdown. A SIGTERM from systemd or Docker waits for in-progress requests to finish, for up to 10 seconds by default, before terminating.

Building the single binary

Order matters: the frontend first, then Go. The go:embed needs to find the web/dist when compiling. In a fresh clone, where the dist doesn't exist because it's in the .gitignore, the go build failure:

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

To avoid relying on memory, leave numeric order in Makefile at the root. Remember that command lines start with 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 generates a static binary that runs on any Linux of the same architecture without depending on glibc;
  • -ldflags "-s -w" removes debugging symbols and reduces the size;
  • -trimpath strips the paths from your machine out of the executable.

To prove there is no dependency on external files, copy the binary to another folder and run it:

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

Open http://127.0.0.1:8080: login, tasks and navigation working, with just one port and one process. For another architecture, just GOARCH=arm64 in go build, because the compiled frontend is the same.

Building with Docker, without installing Go or Node

A Dockerfile multi-stage solves the build on any machine that has Docker, with pinned versions of Node and Go, and also generates a minimal image. Create the Dockerfile at the root:

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

And a .dockerignore, so as not to send it to build node_modules and local artifacts:

.git
bin
web/node_modules
web/dist

The stages help with caching: package.json and go.mod are copied before the code, so dependencies are only downloaded again when they change.

Option 1: just the binary

The stage bin exists for one thing: to extract the executable to your machine using BuildKit's local exporter :

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

You get the same static binary from make build, without having Go or Node installed. It is the simplest way to build on a clean machine or in a CI pipeline.

Option 2: the image

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

The image is based on the scratch, empty. It contains only the binary: no shell, no package manager, nothing for an attacker to take advantage of. Runs as the 65534 user (nobody), and not as root. Inside the container, the APP_ADDR é 0.0.0.0:8080, because 127.0.0.1 inside there would not be reachable through the port mapping; on the host side, the -p 127.0.0.1:8080:8080 continues to expose only locally.

Option 3: Docker Compose

To bring it up with a single command, create 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

The syntax ${APP_PASSWORD:?...} makes Compose refuse to start if the password is not set, instead of starting with an empty value:

required variable APP_PASSWORD is missing a value: defina APP_PASSWORD

To learn the basics of containers, see our Docker course.

Without Docker: systemd

If you prefer the binary directly on the server, a systemd service with its own user and read-only filesystem works. See also the guide on essential systemd commands.

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

The DynamicUser=yes creates a temporary user for the service, and the EnvironmentFile with permission 0600 keeps the password out of the unit and out of the ps. The SIGTERM of the systemctl stop triggers the graceful shutdown.

HTTPS with Nginx in front

The session cookie cannot travel over HTTP outside localhost. Put Nginx in front, with the certificate, and forward X-Forwarded-Proto: this is the header that connects the Secure of the cookie, as we saw in part 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;
    }
}

Only accept X-Forwarded-Proto when Go is listening on 127.0.0.1, behind the proxy. If the port were exposed, any client could send that header.

Files for this part in the gist

Each file opens directly in the gist of the series. The gist brings the final version of the project: main.go and routes.go they also get login in part 4 and the embedded frontend in part 5.

Recapping the series

  • Part 1: environment, Echo v5 and the first route, with the *echo.Context from da v5;
  • Part 2: Vue 3 with Vite, Vue Router, and the development proxy;
  • Part 3: REST API with groups, Bind, validation, and HTTPError;
  • Part 4: login with bcrypt, cookie HttpOnly and authentication middleware;
  • Part 5: go:embed, a single binary of about 10 MB, Docker, systemd, and Nginx.

From here, the natural next steps are to switch in-memory storage to SQLite or PostgreSQL, store sessions in the database, and add tests with the echotest from Echo itself. The series phpVirtualBox in Go demonstrates that same architecture applied to a real-world project.

Vue.js + Go with Echo v5 series: Part 1: environment and first API · Part 2: Vue frontend with Vite · Part 3: tasks REST API · Part 4: login with session and cookie · Part 5: embed, single binary and Docker