DEV Community

Cover image for Single Binary Deploy: Ship Your Whole App as One File
Sarah
Sarah

Posted on

Single Binary Deploy: Ship Your Whole App as One File

TL;DR

  • Compile your entire app — server, templates, static files, migrations, SQLite — into one static binary
  • Go's embed and Rust's rust-embed make this a few lines of code
  • Deploy = scp + atomic mv + systemctl restart. Rollback = the reverse
  • Great for monoliths and small service counts. Not great for multi-node writes or 200 MB frontend bundles
  • Full copy-paste deploy.sh at the bottom

Most deployment pain is a headcount problem. A runtime, a dependency tree, a static folder, a config dir, a container image wrapping all of it — every piece is one more thing that can drift between your laptop and prod.

A single-binary deploy collapses all of it into one executable with zero runtime dependencies. If it runs on staging, it runs on prod, because there's nothing else to install.

Here's the full setup in Go, the Rust equivalent, and where it falls apart.

What goes in the binary

Traditional deploy Single-binary deploy
Runtime (Node, Python, JVM) Compiled in
node_modules / site-packages Compiled in
/static, /templates Embedded
SQL migrations Embedded
Config files Flags + env vars, defaults compiled in
Database server SQLite embedded (optional)
Dockerfile + image Gone

The Go version

Layout

myapp/
├── main.go
├── migrations/001_init.sql
├── static/app.css
└── templates/index.html
Enter fullscreen mode Exit fullscreen mode

Embed everything

package main

import (
    "database/sql"
    "embed"
    "html/template"
    "io/fs"
    "log"
    "net/http"

    _ "modernc.org/sqlite" // pure Go, no CGO
)

//go:embed static/*
var staticFS embed.FS

//go:embed templates/*.html
var templateFS embed.FS

//go:embed migrations/*.sql
var migrationFS embed.FS

func main() {
    db, err := sql.Open("sqlite", "app.db")
    if err != nil {
        log.Fatal(err)
    }
    if err := runMigrations(db); err != nil {
        log.Fatal(err)
    }

    tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html"))
    staticSub, _ := fs.Sub(staticFS, "static")
    http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        tmpl.ExecuteTemplate(w, "index.html", nil)
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

Run migrations from the embedded FS

func runMigrations(db *sql.DB) error {
    entries, err := migrationFS.ReadDir("migrations")
    if err != nil {
        return err
    }
    for _, e := range entries {
        b, err := migrationFS.ReadFile("migrations/" + e.Name())
        if err != nil {
            return err
        }
        if _, err := db.Exec(string(b)); err != nil {
            return err
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

(For real projects, golang-migrate has an iofs driver that reads from embed.FS and tracks versions. Use that.)

Build it

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags --always)" -o myapp .
Enter fullscreen mode Exit fullscreen mode
  • CGO_ENABLED=0 → fully static, no libc
  • GOOS/GOARCH → cross-compile from anywhere
  • -s -w → strip symbols, ~35% smaller
  • -X main.version=... → bake the git tag in so /healthz tells you what's live

One file, ~15 MB, whole app.

The Rust version

use rust_embed::RustEmbed;
use axum::{Router, routing::get, response::Html};

#[derive(RustEmbed)]
#[folder = "static/"]
struct Assets;

async fn index() -> Html<&'static str> {
    Html(include_str!("../templates/index.html"))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(index));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
Enter fullscreen mode Exit fullscreen mode

Deploying it

scp ./myapp deploy@server:/opt/myapp/myapp.new
ssh deploy@server '
  cd /opt/myapp &&
  mv myapp myapp.prev &&
  mv myapp.new myapp &&
  sudo systemctl restart myapp
'
Enter fullscreen mode Exit fullscreen mode

mv on the same filesystem is atomic — no half-written binary ever gets executed. Rollback:

ssh deploy@server 'cd /opt/myapp && mv myapp.prev myapp && sudo systemctl restart myapp'
Enter fullscreen mode Exit fullscreen mode

systemd unit:

[Unit]
Description=myapp
After=network.target

[Service]
User=deploy
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/myapp
Restart=always
RestartSec=2
Environment=PORT=8080
Environment=DATABASE_PATH=/opt/myapp/data/app.db

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Don't want to manage the box at all? Hosted options like RapidNative Deploy take the compiled artifact and handle the hosting layer, so the copy-and-restart step becomes a push.

Config without config files

port := flag.String("port", envOr("PORT", "8080"), "listen port")
dbPath := flag.String("db", envOr("DATABASE_PATH", "app.db"), "sqlite path")
flag.Parse()

func envOr(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}
Enter fullscreen mode Exit fullscreen mode

Precedence: flag → env → compiled default. Runs with zero setup, overridable in the unit file.

Where it breaks

Constraint Reality
Multi-node writes SQLite is single-writer. Litestream/LiteFS help; otherwise Postgres
Huge frontend bundles Embedding 200 MB inflates every deploy — CDN those
Python / Node Works via PyInstaller or bun build --compile, but it's a bolt-on
15+ services An orchestrator still earns its keep at that scale
Zero-downtime restarts You still need http.Server.Shutdown for graceful drain

Sweet spot: a monolith or a handful of services on one or a few boxes. Which is most projects.

The whole deploy script

#!/usr/bin/env bash
set -euo pipefail

APP=myapp
HOST=deploy@your-server
DIR=/opt/$APP
VERSION=$(git describe --tags --always)

echo "Building $APP@$VERSION..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -ldflags="-s -w -X main.version=$VERSION" -o "$APP" .

echo "Uploading..."
scp "$APP" "$HOST:$DIR/$APP.new"

echo "Swapping and restarting..."
ssh "$HOST" "cd $DIR && mv $APP $APP.prev && mv $APP.new $APP && sudo systemctl restart $APP"

echo "Verifying..."
sleep 2
ssh "$HOST" "curl -sf localhost:8080/healthz"
echo
echo "Deployed $VERSION"
Enter fullscreen mode Exit fullscreen mode

Twenty lines. No Docker, no YAML, rollback is one mv away.


If you've been reaching for Docker by default, try shipping your next small service as a single binary. Drop a comment with what you're running this way — or what stopped you from trying it.

Top comments (0)