All articles

Self-hosting a Go SaaS with Docker: multi-stage builds, container hardening, and a one-liner install

Suman Akkisetty
Founder, KaryoSpace
May 2026
10 min read

KaryoSpace ships as a single Docker image. One command pulls and starts the entire stack: Go binary, MongoDB, nginx, all configuration. The install story for a self-hosted org should be measured in minutes, not days. Here is the architecture that makes that possible, and the container hardening that makes it safe to run in production.

Multi-stage Dockerfile

The production image uses a two-stage build. Stage one compiles the Go binary inside a Go toolchain image. Stage two copies only the binary and the frontend HTML into a minimal Alpine runtime. The Go toolchain, module cache, and source code never appear in the final image.

# Stage 1 — Builder
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download           # layer-cached; only re-runs if deps change
COPY backend/go/ .
RUN CGO_ENABLED=0 GOOS=linux go build -o workspace .

# Stage 2 — Runtime
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata curl
RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -D appuser
WORKDIR /app
COPY --from=builder /build/workspace .
COPY frontend /app/frontend     # from project root (build context: .)
USER appuser
EXPOSE 8080 2525 587 1993
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s     CMD curl -f http://localhost:8080/health || exit 1
CMD ["./workspace"]

The build context is the project root (not backend/go/) because the COPY frontend line needs to reach the frontend/ directory. This is set via context: . in docker-compose.yml. If you try to build the Dockerfile manually from backend/go/, the COPY frontend step fails.

The final image is approximately 25 MB: Alpine base (~5 MB) plus the static Go binary (~18 MB) plus the HTML templates (~2 MB). No node_modules, no Python, no build artifacts.

Container hardening

The default Docker security posture is too permissive for a production SaaS. KaryoSpace applies five hardening measures to both the app container and the MongoDB container:

# docker-compose.prod.yml (app service)
services:
  workapp:
    security_opt:
      - no-new-privileges:true   # prevent privilege escalation after start
    cap_drop:
      - ALL                      # drop every Linux capability
    cap_add:
      - NET_BIND_SERVICE         # needed to bind port 587 (SMTP submission)
    read_only: true              # root filesystem is read-only
    tmpfs:
      - /tmp                     # writable tmpfs for temp files only

  mongodb:
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
      - DAC_OVERRIDE             # needed for MongoDB file ownership at init
    read_only: true
    tmpfs:
      - /tmp

cap_drop: ALL is the most important line. It removes capabilities like CAP_NET_RAW (raw socket access), CAP_SYS_PTRACE (process tracing), and CAP_SYS_ADMIN (everything). If the app is compromised, the attacker operates in a heavily restricted environment.

MongoDB initialisation

MongoDB starts with no users. The first-ever container start runs infra/mongo-init.js via the MONGO_INITDB mechanism. This script creates two users: a root admin for bootstrap operations, and a least-privilege app user that the Go binary actually connects as.

// infra/mongo-init.js
db.getSiblingDB('admin').createUser({
    user: process.env.MONGO_ROOT_USERNAME,
    pwd:  process.env.MONGO_ROOT_PASSWORD,
    roles: [{ role: 'userAdminAnyDatabase', db: 'admin' },
            { role: 'readWriteAnyDatabase', db: 'admin' }]
});
db.getSiblingDB(process.env.MONGO_DATABASE).createUser({
    user: process.env.MONGO_APP_USER,
    pwd:  process.env.MONGO_APP_PASSWORD,
    roles: [{ role: 'readWrite', db: process.env.MONGO_DATABASE }]
});

The Go binary connects as MONGO_APP_USER. It has read-write access to the workspace database and nothing else. It cannot list other databases, create users, or access the admin database. A MongoDB injection that escalates to db.adminCommand() finds a permission denied error, not a root shell.

The one-liner install

Self-hosted customers install via a curl-pipe script at get.karyospace.com. The script checks for Docker and Compose, creates a ~/workspace directory, downloads the Compose file and .env.example, prompts for essential configuration, auto-generates secrets, and starts the stack.

curl -fsSL https://get.karyospace.com | bash

Secret generation uses openssl rand so the install script does not depend on Python, Node, or any language runtime beyond bash:

SESSION_KEY=$(openssl rand -base64 32)
DKIM_KEY=$(openssl rand -base64 32)
MONGO_ROOT_PASS=$(openssl rand -hex 16)
MONGO_APP_PASS=$(openssl rand -hex 16)

# Write to .env if it does not exist (idempotent on re-run)
if [ ! -f .env ]; then
    sed -e "s/SESSION_STORE_KEY=.*/SESSION_STORE_KEY=$SESSION_KEY/"         -e "s/DKIM_ENCRYPTION_KEY=.*/DKIM_ENCRYPTION_KEY=$DKIM_KEY/"         .env.example > .env
fi

The idempotent check matters. If an org runs the installer twice (perhaps after an outage), the second run does not overwrite their secrets. The .env file they configured the first time is preserved.

Health check design

The health endpoint returns 200 if the binary is responding and MongoDB is reachable. It explicitly does not check LLM provider availability. Groq rate limits and Ollama startup time caused health check failures in early deploys. An app that answers HTTP requests but cannot call Groq is not unhealthy; it is degraded. Those are different states and should not trigger a container restart.

func healthHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()
    if err := mongoClient.Ping(ctx, nil); err != nil {
        http.Error(w, "db unavailable", 503)
        return
    }
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

Deployment in production

The deploy script builds a fresh ARM64 binary locally, uploads it to the prod VM, copies it into the running container alongside all frontend HTML, and restarts the container. It does not pull from a registry. The binary and HTML are always deployed atomically — a binary-only deploy that leaves old HTML templates in place caused a production incident early on and the deploy script was hardened to prevent it.

Get in touch

Questions about KaryoSpace or this article? We'd love to hear from you.

Contact Us
Message sent!
We read every submission and will get back to you.