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.
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.
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 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.
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.
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"})
}
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.
Questions about KaryoSpace or this article? We'd love to hear from you.