TL;DR
- Compile your entire app — server, templates, static files, migrations, SQLite — into one static binary
- Go's
embedand Rust'srust-embedmake this a few lines of code - Deploy =
scp+ atomicmv+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.shat 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
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))
}
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
}
(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 .
-
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/healthztells 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();
}
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
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
'
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'
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
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
}
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"
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)