Running Go apps on Unikraft Cloud: Unikernels for Go developers
Go produces static binaries. No runtime dependencies, no VM, no interpreter, just one file that runs on Linux. Most of us use that property to make small Docker images and stop there. But it enables something more radical: unikernels, and Unikraft is betting on it.
Unikraft Cloud deploys applications as unikernels: single-purpose virtual machines that bundle your app with just enough OS to run it. No shell, no package manager, no SSH. The result boots in milliseconds, uses a fraction of the memory a container would, and gives an attacker very little to work with.
If you write Go services, this is worth understanding even if you never deploy one.
What are unikernels and why should Go developers care?
A unikernel strips away everything a general-purpose OS provides that your application doesn’t need. Think about what your Go binary already carries: its own scheduler (the Go runtime), its own memory management, its own networking via the net package. A full Linux kernel underneath is doing a lot of work your microservice never asked for.
Unikraft is the open-source project that builds these minimal unikernels. Their cloud platform, Unikraft Cloud, handles the deployment side: you push your Go app and they run it as a unikernel instance.
The fit with Go is real rather than marketing. CGO_ENABLED=0 go build gives you a self-contained binary with no shared libraries to worry about. Go binaries already start fast, and paired with a kernel that boots in single-digit milliseconds you get genuine scale-to-zero. A Go HTTP server that uses 10-15MB of RSS can run in a VM whose total memory, kernel included, is as low as 32MB.
If you’re already building Go services with proper context handling and clean shutdown semantics, you’re most of the way there.
How Unikraft runs Go binaries
Unikraft uses Kraftkit, their CLI tool (itself written in Go), to build and deploy applications. For Go apps, the workflow leans on Go’s ELF binary output targeting Linux.
The deploy pipeline is short: you build your Go binary for Linux/amd64, Kraftkit packages it with a minimal Unikraft kernel, and out comes a unikernel image that boots as a VM.
The key file is a Kraftfile in your project root. Here’s one for a Go HTTP service:
spec: v0.6
runtime: base:latest
rootfs: ./Dockerfile
cmd: ["/server"]
And the corresponding Dockerfile that builds your Go binary:
FROM golang:1.22 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /server .
FROM scratch
COPY --from=build /server /server
Notice the FROM scratch. This matters: the unikernel has no Linux userspace, so your binary must be fully static, and CGO_ENABLED=0 stops the Go toolchain linking against libc.
Building a Go HTTP service for Unikraft
Let’s build a small service that behaves well in a unikernel. The constraints to keep in mind: no filesystem access beyond what you explicitly mount, no shell or external processes (os/exec won’t work), and networking goes through virtio, which the Go net package handles without you noticing.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
type HealthResponse struct {
Status string `json:"status"`
Timestamp int64 `json:"timestamp"`
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
resp := HealthResponse{
Status: "ok",
Timestamp: time.Now().Unix(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("running on unikraft"))
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Printf("listening on :%s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
<-ctx.Done()
log.Println("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("shutdown error: %v", err)
}
}
A few things worth noting. The signal.NotifyContext pattern handles graceful shutdown cleanly, and it earns its keep here: the VM itself might be killed, but handling SIGTERM properly means Unikraft can drain connections before teardown. If you want to dig into Go’s HTTP server patterns, see how the standard library HTTP server works.
The ReadTimeout, WriteTimeout, and IdleTimeout values matter more than usual too. The VM is purpose-built for this one service, so a leak from idle connections eats a proportionally bigger chunk of your tight memory budget.
Deploying with Kraftkit
Once you have your Kraftfile and Dockerfile, deployment looks like this:
# Install kraftkit
curl --proto '=https' --tlsv1.2 -sSf https://get.kraftkit.sh | sh
# Deploy to Unikraft Cloud
kraft cloud deploy --metro fra0 -p 443:8080 .
The --metro flag picks the data centre. The -p 443:8080 maps external port 443 to your app’s port 8080 with automatic TLS termination.
Kraftkit itself is worth a look if you build CLI tools. It’s a Go application that talks to the Unikraft Cloud API, calls out to Docker for the multi-stage build, then packages the resulting binary with the appropriate Unikraft kernel. The command handling is cobra, as it is in most serious Go CLIs.
Go-specific gotchas with unikernels
CGO is your enemy
If any dependency pulls in CGO, your binary won’t work on scratch and likely won’t work in a unikernel. This is the thing that will actually bite you. Common offenders:
github.com/mattn/go-sqlite3— usemodernc.org/sqliteinstead (pure Go)- DNS resolution with CGO resolver — set
GODEBUG=netdns=goor useCGO_ENABLED=0
You can check for CGO dependencies in your binary:
// build_check.go — run this as a build verification step
package main
import (
"fmt"
"os"
"os/exec"
"strings"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: go run build_check.go <binary>")
os.Exit(1)
}
out, err := exec.Command("go", "version", "-m", os.Args[1]).Output()
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
if strings.Contains(string(out), "CGO_ENABLED=1") {
fmt.Println("WARNING: binary was built with CGO enabled")
os.Exit(1)
}
fmt.Println("OK: binary is pure Go")
}
No filesystem by default
The unikernel runs from a read-only root filesystem. If your Go app writes temp files, log files, or caches to disk, you either need an in-memory approach (a bytes.Buffer instead of a temp file) or a writable volume configured in your Kraftfile.
The Go runtime still works
This is the good news. Go’s goroutine scheduler, garbage collector, and net package all work. The Go runtime talks to the kernel through syscalls, and Unikraft implements the Linux syscall interface that Go expects. You don’t modify your Go code for unikernel compatibility. You just avoid OS-level dependencies that aren’t there.
When does this make sense?
Unikernels aren’t for every Go service. They make the most sense for stateless API services that talk to external databases, for scale-to-zero workloads where cold start time is the whole game, for security-sensitive services where a small attack surface buys you something concrete, and for edge deployments where memory is genuinely scarce.
If your Go service shells out to external tools, writes extensively to the filesystem, or needs debugging tools in production, stick with containers. Honestly, that describes most services, and containers are fine. But if you’re building something like the functional options pattern into a clean, well-structured Go service with no external dependencies, a unikernel deployment is more straightforward than you’d expect.
Wrapping up
The mechanics here are simple: build with CGO_ENABLED=0, use a FROM scratch Docker stage, write a Kraftfile, run kraft cloud deploy. The hard part is discipline rather than tooling. No shell in production means no execing into a box to poke around when things go wrong, so your logging and metrics need to be good before you deploy, not after. That’s a discipline worth building whether or not unikernels catch on.
If you’re curious, try it with a small internal service. Deploy it to Unikraft Cloud alongside its container version and compare the boot times and memory numbers yourself. Worst case, you’ll learn exactly how much operating system your service actually needs, which is a useful thing to know.