Recently I've become quite a fan of Go, a pragmatic and flexible programming language that has opened up new ways for me to build applications.

In this post, I wanna show how to embed an entire JavaScript/TypeScript SPA (Single Page Application) directly into your go binary to simplify the deployment of small applications. I've used this approach already in multiple smaller projects to streamline the build and deployment pipeline.

What problem are we solving here?

The "SPA with backend" architecture consists of two components that are deployed in very different ways: The backend is a single binary or container providing it's own HTTP service, while the frontend is a bunch of static files.

If you're using a reverse proxy that cannot serve files from the filesystem (like Traefik or some hosted solution in the cloud), you have to deploy a file-serving HTTP server like nginx as well and provide the files to it. Setting this up can be annoying in container-based environments.

This also creates an inconsistent build and deployment experience, which often leads to complex CI setups or additional scripting to put everything into the right place. If you're using a container-based GitOps approach, the SPA part doesn't really fit into the otherwise very standardized picture.

<p>CI/CD steps with separated frontend and backend pipelines.</p>

CI/CD steps with separated frontend and backend pipelines.

By embedding the entire SPA into your webservice, you completely get rid of that complexity: Your frontend and backend become a single unit from operations perspective.

<p>CI/CD steps with combined frontend and backend build and deployment process.</p>

CI/CD steps with combined frontend and backend build and deployment process.

This is made possible by Go's embed functionality, which allows you to add files or entire directories into the final binary. Depending on your deployment environment, you might not even need containers and can just run the resulting binary using systemd.

When shouldn't you do that?

If you're building a large-scale or global application that is deployed to multiple containers or servers, it'd recommend against this approach. There are multiple reasons for that:

  • When using rolling updates, different users might receive different versions of your app. Depending on your load balancer setup, users might even receive randomly files of different applications versions, causing breakages that are impossible to debug.
  • Properly configuring caching is harder.
  • Adding huge files or downloads will blow up your binary size.
  • You create a closer dependency between backend and frontend, which might not be desired if both parts are developed by different teams.

In these cases, instead go for a CDN like Cloudflare or Bunny.

The setup

Let's start by creating some boilerplate projects. To be able to follow, you should have these tools installed:

Now initialize a demo project:

Bash
# Project directory
mkdir embed-project
cd embed-project

# Go backend
mkdir backend
cd backend
go mod init embed-test
echo "frontend/" >.gitignore
cd ..

# Vite frontend (chose whatever options you like when asked)
npm create vite@latest ./frontend
cd frontend
npm install
cd ..

Build, embed and register

For this example, I'm using the fiber framework, which has served me well for multiple projects.

To not make my main.go files become too cluttered, I like to put certain routes into separate files and use registration functions for the routes.

But before, build your JS application once and copy it over into the backend:

Bash
cd frontend; npm run build; cd ..
rm -rf ./backend/frontend
cp -a frontend/dist ./backend/frontend

You need to repeat these steps every time you update your frontend. Putting that into a Makefile or bash script can help your sanity :)

Now, create the file where the magic happens:

golang-embed-spa/embedded_app.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
    "embed"
    "net/http"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/filesystem"
)

//go:embed frontend/*
var frontendFiles embed.FS

func RegisterEmbeddedAppRoutes(app *fiber.App) {
    app.Use("/", filesystem.New(filesystem.Config{
        Root:       http.FS(frontendFiles),
        PathPrefix: "frontend",
        Index:      "index.html",
    }))
}

In your main file, you can now register the app route:

golang-embed-spa/main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
    "log"

    "github.com/gofiber/fiber/v2"
)

func main() {
    log.Printf("Setting up HTTP server")
    app := fiber.New(fiber.Config{
        AppName:           "backend",
        CaseSensitive:     true,
        StrictRouting:     true,
        EnablePrintRoutes: false,
    })

    RegisterEmbeddedAppRoutes(app)

    log.Printf("Listening on http://%s/", "0.0.0.0:9000")
    if err := app.Listen("0.0.0.0:9000"); err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
}

Building everything as container

Using the multi-stage feature provided by modern container builders, you can build the frontend and backend binary in a single build step. The resulting container will be quite minimal and only contain your binary and some essential files.

golang-embed-spa/Dockerfile
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# ==============================================================================
# Stage 1: Build frontend
#

FROM node:22 AS frontend

WORKDIR /app

# Install dependencies
COPY frontend/package.json frontend/package-lock.json /app/
RUN npm install --include=dev

# Copy entire application code
COPY frontend frontend

# End result will be in: /app/frontend/dist
RUN cd frontend; npm run build

# ==============================================================================
# Stage 2: Build Backend
#

# Build the application from source
FROM golang:1.24 AS backend

WORKDIR /app

COPY backend/go.mod backend/go.sum /app/
RUN go mod download

COPY backend backend
COPY --from=frontend /app/frontend/dist /app/backend/frontend

RUN cd backend; CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /backend

# ==============================================================================
# Stage 3: Final image
#

# Deploy the application binary into a lean image
FROM gcr.io/distroless/base-debian12

COPY --from=backend /backend /backend

USER nonroot:nonroot

ENTRYPOINT ["/backend"]

The resulting container image is generally quite small and can be run in any container runtime.

In my most recent project, the resulting container image weighted around 41 MiB and the application itself used around 5 MiB of RAM during runtime.