Running Go apps on Unikraft Cloud: Unikernels for Go developers
Go produces static binaries. No runtime dependencies, no VM, no interpreter. Just a single binary that runs on Linux. This property makes Go one of the best languages for unikernels — and Unikraft is betting on that.
Unikraft Cloud lets you deploy applications as unikernels: single-purpose virtual machines that bundle your app with just enough OS to run. No shell, no package manager, no SSH. The result is a VM that boots in milliseconds, uses a fraction of the memory a container would, and has a drastically smaller attack surface.
If you write Go services, this is worth understanding.
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. Your Go binary already contains its own scheduler (the Go runtime), its own memory management, and its own networking via the net package. A full Linux kernel underneath is overkill for most Go microservices.
Unikraft is an 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 Go-specific advantages are real:
- Static compilation:
CGO_ENABLED=0 go buildgives you a self-contained binary. No shared libraries to worry about. - Fast startup: Go binaries start fast already. Pair that with a unikernel that boots in single-digit milliseconds and you get true scale-to-zero.
- Small memory footprint: A Go HTTP server might use 10-15MB of RSS. In a unikernel, total VM memory can be as low as 32MB including the kernel.
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 relies on Go’s ELF binary output targeting Linux.
Here’s what happens when you deploy a Go app to Unikraft Cloud:
- You build your Go binary targeting Linux/amd64
- Kraftkit packages it with a minimal Unikraft kernel
- The result is a unikernel image that boots as a VM
The key file is a Kraftfile in your project root. Here’s what one looks like 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 is critical. The unikernel doesn’t have a Linux userspace. Your binary needs to be fully static. CGO_ENABLED=0 ensures the Go toolchain doesn’t link against libc.
Building a Go HTTP service for Unikraft
Let’s build a small service that works well in a unikernel environment. The constraints are:
- No filesystem access beyond what you explicitly mount
- No shell or external processes (
os/execwon’t work) - Networking works through virtio — the Go
netpackage handles this transparently
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 to note here. The signal.NotifyContext pattern handles graceful shutdown cleanly. In a unikernel, the VM itself might be killed, but handling SIGTERM properly means Unikraft can drain connections before teardown. If you want to learn more about Go’s HTTP server patterns, check out how the standard library HTTP server works.
The ReadTimeout, WriteTimeout, and IdleTimeout values matter more in a unikernel context. Since the VM is purpose-built for this one service, resource leaks from idle connections have a proportionally bigger impact on 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 center. The -p 443:8080 maps external port 443 to your app’s port 8080 with automatic TLS termination.
What’s interesting from a Go perspective is what Kraftkit does internally. It’s a Go application that uses the Unikraft Cloud API. The build process calls out to Docker for the multi-stage build, then packages the resulting binary with the appropriate Unikraft kernel. The CLI uses cobra for command handling — a pattern you’ll find in most Go CLI tools.
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. 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 need to either:
- Use an in-memory approach (e.g.,
bytes.Bufferinstead of temp files) - Configure a writable volume 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 need to modify your Go code for unikernel compatibility — you just need to 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 handle HTTP requests and talk to external databases
- Scale-to-zero workloads where cold start time matters
- Security-sensitive services where a minimal attack surface is valuable
- Edge deployments where memory and compute are constrained
If your Go service shells out to external tools, writes extensively to the filesystem, or needs debugging tools in production, stick with containers. 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 straightforward.
Wrapping up
Unikraft Cloud is an interesting deployment target for Go developers because Go’s compilation model aligns perfectly with what unikernels need: a single static binary with no runtime dependencies. The Go runtime handles its own scheduling and memory management, so the minimal kernel that Unikraft provides is enough.
The practical steps are simple: build with CGO_ENABLED=0, use a FROM scratch Docker stage, write a Kraftfile, and deploy with kraft cloud deploy. The harder part is making sure your Go code doesn’t depend on OS features that aren’t available — but if you’re writing clean Go services, you’re probably already there.