Deployment

Build from the monorepo root and deploy apps with clear module boundaries.

GO(A)HT apps still compile to single Go binaries, but deployment guidance needs to match the repository shape you actually run. In a monorepo, builds usually happen from the repo root, Dockerfiles often live inside an app, and shared libs must stay inside the module boundaries the app already depends on.

Start From The Build Context

If an app imports shared packages from the same repo, build it from a context that includes those packages.

Typical monorepo layout:

repo/
├── apps/
│   ├── bagtrax/
│   │   ├── cmd/server/main.go
│   │   ├── Dockerfile
│   │   ├── templates/
│   │   └── static/
│   └── marketing/
├── libs/
│   ├── authkit/
│   ├── ssehub/
│   └── web/
├── go.mod
└── go.sum

In that layout, apps/bagtrax/Dockerfile should usually be built with the repo root as the Docker context:

docker build -f apps/bagtrax/Dockerfile .

Not:

docker build apps/bagtrax

The root-context build can see go.mod, go.sum, shared libs, and the app directory in one build graph.

Root-Context Dockerfile

An app-local Dockerfile can still assume a root build context:

FROM golang:1.24-alpine AS build
WORKDIR /src

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

COPY libs ./libs
COPY apps/bagtrax ./apps/bagtrax

RUN CGO_ENABLED=0 go build -o /out/server ./apps/bagtrax/cmd/server

FROM alpine:3.20
WORKDIR /app
COPY --from=build /out/server ./server
COPY --from=build /src/apps/bagtrax/templates ./templates
COPY --from=build /src/apps/bagtrax/static ./static

EXPOSE 8080
CMD ["./server"]

That pattern keeps the Dockerfile close to the app while preserving the correct module view during the build.

Module Boundaries Matter

Deploy each app according to the Go module or workspace boundaries it already uses.

Rules:

  • an app may import shared libs that are already part of the root module or workspace
  • a deploy target should build exactly the app entrypoint it serves
  • do not copy unrelated apps into the runtime image just because they share a repo
  • do not bypass module boundaries with ad hoc replace hacks just for deployment

If a service only needs ./apps/bagtrax/cmd/server, build that binary. If another app in the monorepo ships separately, give it its own image and deploy unit.

Single-App Repos Still Work

If the repo is a single app with no sibling apps or shared libs, the same principle becomes simpler:

COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

The key point is not the exact path. The key point is that the Docker build context must include the module inputs the binary needs.

Runtime Assets

If you do not embed templates and static files, copy them into the runtime image:

COPY --from=build /src/apps/bagtrax/templates ./templates
COPY --from=build /src/apps/bagtrax/static ./static

That keeps page HTML and fragment rendering working in production. If you use go:embed, you can ship a smaller runtime surface, but that is a packaging choice, not a GO(A)HT requirement.

Platform Deploys

Platform-as-a-service deploys should use the same build assumptions:

  • set the build context to the repo root when the app depends on root-level modules or libs
  • point the start command at the app binary for that module boundary
  • keep environment variables scoped to the deployed app, not the whole monorepo

The app may live in apps/bagtrax, but the build context may still need to be the repo root.

Direct Binary Deploys

The same module-boundary rule applies when building outside Docker:

go build -o ./dist/bagtrax ./apps/bagtrax/cmd/server

Run that command from the repo root so Go resolves the same module graph you use in development.

Health And Configuration

Expose a health endpoint and drive config through environment variables:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("OK"))
    })

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    log.Fatal(http.ListenAndServe(":"+port, mux))
}

Treat app-specific environment values the same way you treat app-specific binaries: by boundary, not by repo-wide accident.

Gotchas

  • A Dockerfile under apps/... does not imply apps/... should be the Docker build context.
  • If the image build cannot see go.mod or shared libs, the context is wrong.
  • Copy only the assets for the app you are deploying, not every app in the monorepo.
  • Keep deployment units aligned with module and ownership boundaries. Shared code can be shared at build time without forcing shared runtime images.