Deploy a Go web app
One Go binary on a VM of its own. git push to deploy.
Shelley handles the setup. About 10 minutes.
Prefer a terminal?
curl -fsSL https://exe.dev/deploy/go/prompt.txt | ssh exe.dev new --prompt=/dev/stdinGo builds your web app into one binary; this page gives it a home. An
exe.dev VM runs that binary under systemd, keeps its SQLite database on a
real disk, and rebuilds and restarts it on every git push, like Heroku
did. exe.dev puts it on HTTPS, behind its own login until you decide
otherwise.
The VM is always on and keeps its files: no cold starts, no request time
limits, and background goroutines keep running. You can SSH in, read the
logs, or ask the agent on the VM what went wrong. Bring a repository from
GitHub, or start from a small net/http app with SQLite, migrations, and
its templates compiled into the binary.
What you get#
- The app's binary answering on port 8000 as app.service, enabled to come back after a reboot.
- A ~/app.git repository whose pre-receive hook builds each push to main into a new release and rejects the push if the deploy fails.
- Settings and secrets in ~/app.env (mode 600), read by the build and by the server.
- Data in ~/data, backed up nightly by app-backup.timer, with the newest backup opened and checked in a scratch directory.
- With REPO=new, a net/http guestbook that saves messages to SQLite, with its templates and static files embedded in the binary.
What's running, and where
https://my-go.exe.xyz/
Your app, until you make it public
my-go.exe.xyz:app.git
git push exe main deploys
Deploy with Shelley#
Click Deploy and Shelley will set up Go on a new VM, then check that it works. No config-file wrangling required.
Have these ready; Shelley asks for them:
REPO: the GitHub repository to deploy, as owner/name or a URL, ornewto start a fresh Go app
Prefer a terminal?
curl -fsSL \
https://exe.dev/deploy/go/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhat Shelley is told
REPO=<fill-this-in>
Set up Go on this VM by following the exe.dev recipe at https://exe.dev/deploy/go.md from top to bottom.
The values at the top of this message are mine:
- REPO: the GitHub repository to deploy, as owner/name or a URL, or `new` to start a fresh Go app
Ask me for any that still say <fill-this-in> before you need them.
The recipe's example VM name is my-go. This VM's name is the output of `hostname`: use it wherever the recipe writes my-go. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- The app's binary answering on port 8000 as app.service, enabled to come back after a reboot.
- A ~/app.git repository whose pre-receive hook builds each push to main into a new release and rejects the push if the deploy fails.
- Settings and secrets in ~/app.env (mode 600), read by the build and by the server.
- Data in ~/data, backed up nightly by app-backup.timer, with the newest backup opened and checked in a scratch directory.
- With REPO=new, a net/http guestbook that saves messages to SQLite, with its templates and static files embedded in the binary.
Read the whole recipe, including the closed details. Follow "Set up by
hand" from "Check Go" to the end of it, as exedev; the walkthrough
above it is for the owner afterwards. Do not run Restore or Update, and
do not reboot. Leave public sharing off.
REPO picks one path in "Get the code": `new` starts a fresh app;
anything else is a GitHub repository (turn URLs into owner/name). Never
commit to a repository you cloned: its settings go in ~/app.env. If a
clone fails, follow the private-repository steps, and if that fails
too, give the owner the integration command and wait for them.
For a cloned app, find its main package and set MAIN_PACKAGE; fill
~/app.env from its README or .env.example: make it listen on port 8000,
generate secrets with openssl, ask the owner for keys to outside
services, and never invent values. If a build fails, read its output,
fix what you can without editing the code, and redeploy with
~/app.git/hooks/pre-receive HEAD.
Finish with the app's URL and the git commands the owner runs on their
own computer, from "Deploy with git push": `git clone -o exe` for a new
app, `git remote add exe` for a GitHub repository, then `git push exe
main` (or their default branch).
Finish with the URLs I should open.
Also at /deploy/go/prompt.txt. The recipe it follows is /deploy/go.md.
Open your app#
When setup finishes, open https://my-go.exe.xyz/ and sign in to exe.dev
if asked. A new app shows a guestbook; sign it. Your message is signed
with your exe.dev name, saved in SQLite in ~/data/app.db, and still
there after a redeploy or a reboot.
Deploy with git push#
Your VM holds a git repository, ~/app.git, and deploys whatever you push
to its main branch, like git push heroku main used to. Set it up once
on your computer.
If Shelley started a new app, clone it from the VM:
git clone -o exe my-go.exe.xyz:app.git my-go
cd my-go
If you brought a GitHub repository, add the VM as a second remote in your
usual checkout. GitHub stays your source of truth; exe is where you
deploy:
git remote add exe my-go.exe.xyz:app.git
Then change something, commit, and push:
git push exe main
Use your default branch's name if it isn't main. The build streams back
into your terminal and ends with ==> Deployed abc1234: HTTP 200 on port 8000. If ssh can't find the VM that way, use vm+my-go@vm.exe.xyz:app.git
as the remote's address (why).
Each push runs go build in a new release directory, backs up ~/data,
switches ~/app to the new release, and restarts app.service (well
under a second for a Go binary). If anything fails, including a new
version that doesn't answer, the old version comes back and git rejects
the push (pre-receive hook declined), so the VM's main stays at the
commit that's running, as on Heroku.
To roll back, push an older commit: git push -f exe <good-sha>:main.
Build on your computer#
If you'd rather ship the binary you tested, cross-compile it and hand it to the hook, which deploys it with the same backup, health check, and rollback:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -o app-linux .
ssh my-go.exe.xyz app.git/hooks/pre-receive --binary - < app-linux
The release holds only the binary, so embed what it needs with
//go:embed, as the starter does, and avoid cgo packages such as
mattn/go-sqlite3. The next git push builds from source again.
Settings and secrets#
Environment variables live in one file on the VM, ~/app.env, as
KEY=value lines (single-quote values with spaces or $). Your code
reads them with os.Getenv when it starts, so a restart is enough:
nano ~/app.env
sudo systemctl restart app
MAIN_PACKAGE (. or, say, ./cmd/server) tells the hook what to
build. The nightly backup includes the file.
Your database#
A new app keeps SQLite at ~/data/app.db
(DATABASE_URL=file:/home/exedev/data/app.db), through the pure Go
modernc.org/sqlite driver. It
migrates itself on start, running new files from migrations/ in order.
To add a column, on your computer:
echo 'ALTER TABLE messages ADD COLUMN likes INTEGER NOT NULL DEFAULT 0;' > migrations/0002_likes.sql
git add migrations && git commit -m "Add likes" && git push exe main
The hook backs up ~/data before each switch. A rollback doesn't undo a
migration, so add columns rather than renaming them. With goose or
golang-migrate, set MIGRATE_COMMAND in ~/app.env (say,
'bin/app migrate'); the hook runs it before the switch. For
PostgreSQL, see PostgreSQL.
Keep uploads and other files your app writes in ~/data too; releases
are replaced on every deploy.
Routers and the proxy#
exe.dev's HTTPS proxy handles TLS and talks plain HTTP to
port 8000, so don't call ListenAndServeTLS or set up autocert. The
client's address is the last entry of X-Forwarded-For (r.RemoteAddr
is the proxy's; earlier entries are whatever the client sent), and the
public scheme and host are in X-Forwarded-Proto and X-Forwarded-Host.
The standard library's mux, which the starter uses, matches methods and
path parameters (GET /posts/{id}). chi, gin, and Echo are all
http.Handlers and run the same way; pass them to an http.Server so
you keep the graceful shutdown. For the right client IP, call gin's
r.SetTrustedProxies([]string{"127.0.0.1"}), set Echo v5's
e.IPExtractor = echo.ExtractIPFromXFFHeader(), and skip chi's
middleware.RealIP, which believes the first entry.
Make it public, add a domain#
The app is private: only people you've shared it with can open it, after signing in to exe.dev. When you're ready for the world, on your computer:
ssh exe.dev share set-public my-go
ssh exe.dev share set-private my-go closes it again. To serve it on
your own domain, point a CNAME at my-go.exe.xyz and run
ssh exe.dev domain add my-go app.example.com; exe.dev issues the
certificate (custom domains). The app needs no change.
Once it's public, visitors aren't signed in unless they sign in on
purpose; see the next section.
Let exe.dev handle sign-in#
For visitors signed in to exe.dev, the proxy adds X-ExeDev-Email and
X-ExeDev-UserID headers (login with exe); the
starter signs messages with the email. On a private app, that's everyone
who gets in. On a public one, visitors have no header until they sign in
at https://my-go.exe.xyz/__exe.dev/login?redirect=/. To require sign-in
for some pages, wrap them in a handler that sends visitors there:
// requireLogin sends visitors who aren't signed in to exe.dev to its login page.
func requireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-ExeDev-Email") == "" {
login := "/__exe.dev/login?redirect=" + url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, login, http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
Then mux.Handle("/admin/", requireLogin(admin)), and compare the email
with a list in ~/app.env if only some people should get in.
Cron jobs#
Schedule work with a systemd timer, the way the nightly backup runs: copy
app-backup.service and .timer under a new name, and point ExecStart
at your job, such as /home/exedev/app/bin/app cleanup for a subcommand
of your binary (add WorkingDirectory=/home/exedev/app and
EnvironmentFile=/home/exedev/app.env). For work every few seconds, a
goroutine with a time.Ticker is fine too: the VM doesn't sleep.
Keep your data#
Every night, ~/bin/app-backup saves ~/data (SQLite through its
.backup command, so the copy is consistent), ~/app.env, and the whole
~/app.git repository into ~/backups/app-nightly-<time>.tar.gz. Each
deploy saves an app-deploy- backup first. It keeps the 14 newest of
each kind. A backup on a lost VM won't help much, so copy them off now
and then. On your computer:
scp -r my-go.exe.xyz:backups ./my-go-backups
The files hold your secrets; keep them private. To make an extra backup
before something risky, run ~/bin/app-backup on the VM; it prints the
archive's name.
For staging, ssh exe.dev cp my-go my-go-staging copies the whole VM;
push there first (dev, test, prod).
Ask Shelley#
Shelley, at https://my-go.shelley.exe.xyz/, works on this VM with you.
Try: "The last deploy failed; read the output and tell me why", "Show me
today's errors from journalctl -u app", "Add a JSON API for the
guestbook", or "Move my database to PostgreSQL on this VM".
The nuts and bolts#
Happy with what Deploy set up? You can stop here. These are the setup steps Shelley follows, which you can run by hand, and the commands for later.
Set up by hand
Create the VM#
On your computer (pick another name if my-go is taken):
ssh exe.dev new --name=my-go
ssh my-go.exe.xyz
Everything else runs on the VM, as exedev.
Check Go#
Go is preinstalled in /usr/local/go, with go on the PATH that
systemd, the git hook, and your shell all use:
go version
go env GOTOOLCHAIN
Expect go1.27 or later and auto. With auto, a build whose go.mod
asks for a newer Go (go 1.28, or a toolchain line) downloads that
version by itself, so each app's go.mod decides which Go builds it.
Settings, data, and the service#
Create the data and release directories and a private settings file.
This leaves an existing ~/app.env alone:
mkdir -p ~/data ~/releases ~/backups
if [ ! -e ~/app.env ]; then
install -m 600 /dev/null ~/app.env
cat >> ~/app.env <<'EOF'
# Settings and secrets for the app: KEY=value, one per line. Quote values
# with spaces or $ in 'single quotes'. Read by the build and by app.service.
# After changing it: sudo systemctl restart app
# The package the deploy hook builds, such as ./cmd/server:
MAIN_PACKAGE=.
EOF
fi
stat -c '%a %n' ~/app.env
Expect 600 /home/exedev/app.env. Now the service. It runs the binary the
hook builds, ~/app/bin/app, in ~/app, which will point at the current
release. PORT=8000 is where exe.dev's proxy sends
https://my-go.exe.xyz/:
sudo tee /etc/systemd/system/app.service >/dev/null <<'EOF'
[Unit]
Description=Go app (~/app/bin/app)
After=network-online.target
Wants=network-online.target
[Service]
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
Environment=PORT=8000
ExecStart=/home/exedev/app/bin/app
Restart=always
RestartSec=3
# systemctl stop/restart sends SIGTERM, then SIGKILL after this long.
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app.service
It starts with the first deploy. If your app takes its port from a flag
rather than PORT, add it to ExecStart, such as
/home/exedev/app/bin/app -addr :8000, and run sudo systemctl daemon-reload.
Back up every night#
~/bin/app-backup saves everything that isn't in a release: ~/data
(each SQLite database through .backup, which is safe while the app
writes, and then checked), ~/app.env, and the repository in
~/app.git as a git bundle. A new app's only copy lives there until you
clone it. It keeps the 14 newest backups of each kind: nightly,
deploy (the hook takes one before each switch), and manual.
mkdir -p ~/bin
cat > ~/bin/app-backup <<'EOF'
#!/usr/bin/env bash
# Back up ~/data, ~/app.env and ~/app.git to ~/backups/app-<kind>-<time>.tar.gz.
# SQLite databases are copied with .backup, safe while the app runs, and
# checked. Keeps the 14 newest backups of each kind.
# Usage: app-backup [nightly|deploy|manual]
set -euo pipefail
umask 077
kind=${1:-manual}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$HOME/backups" "$work/data"
cd "$HOME/data"
rsync -a --exclude='*.db' --exclude='*.db-*' --exclude='*.sqlite*' ./ "$work/data/"
find . -type f \( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) | while read -r db; do
mkdir -p "$work/data/$(dirname "$db")"
sqlite3 "$db" ".backup '$work/data/$db'"
[ "$(sqlite3 "$work/data/$db" 'PRAGMA quick_check')" = ok ]
done
cp "$HOME/app.env" "$work/"
if git -C "$HOME/app.git" rev-parse -q --verify HEAD >/dev/null; then
git -C "$HOME/app.git" bundle create -q "$work/app.bundle" --all
fi
out="$HOME/backups/app-$kind-$(date -u +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$out.part" -C "$work" .
mv "$out.part" "$out"
ls -1t "$HOME/backups/app-$kind-"*.tar.gz | tail -n +15 | xargs -r rm --
echo "$out"
EOF
chmod +x ~/bin/app-backup
Run it every night with a systemd timer:
sudo tee /etc/systemd/system/app-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up the app's data, settings and repository to ~/backups
[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/bin/app-backup nightly
EOF
sudo tee /etc/systemd/system/app-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the app every night
[Timer]
OnCalendar=*-*-* 03:00:00 UTC
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer
systemctl list-timers app-backup.timer
Expect the next run, tonight. You'll take the first backup once the app is deployed.
Get the code#
The VM deploys from a bare git repository, ~/app.git. Create it one of
two ways.
Start a new app (REPO=new): an empty repository whose branch is
main.
git init --bare -b main ~/app.git
Bring your GitHub repository: a copy of it. A public repository
clones directly; replace OWNER/NAME:
git clone --bare https://github.com/OWNER/NAME.git ~/app.git
git -C ~/app.git symbolic-ref --short HEAD
The last line prints the branch that will deploy: your default branch.
For a private repository, connect GitHub to exe.dev once (on the Integrations page) and attach the repository to this VM. On your computer:
ssh exe.dev integrations add github --name go-repo --repository OWNER/NAME --readonly --attach vm:my-go
Then clone through the integration on the VM. No token lands on the disk (GitHub integration):
git clone --bare https://github.int.exe.xyz/OWNER/NAME.git ~/app.git
If that clone fails with "not found" or a 403, the integration isn't
attached to this VM yet. If your app needs other private modules, the
VM's build can't download them: run go mod vendor on your computer and
commit vendor/, which go build then uses instead.
Add the deploy hook#
Git runs hooks/pre-receive before it accepts a push, as Heroku does.
This one builds the pushed commit in its own directory under
~/releases, backs up ~/data, switches ~/app to it, restarts the
service, and checks that it answers. If any step fails, it switches
back and rejects the push, so main on the VM is always what's running.
cat > ~/app.git/hooks/pre-receive <<'EOF'
#!/usr/bin/env bash
# Deploys the app. Git runs this before accepting a push to ~/app.git;
# if the deploy fails, the push is rejected and the running version stays.
# To redeploy main by hand: ~/app.git/hooks/pre-receive HEAD
# To deploy a prebuilt linux binary: ~/app.git/hooks/pre-receive --binary FILE (- for stdin)
set -euo pipefail
export GIT_DIR=$HOME/app.git
branch=$(git symbolic-ref --short HEAD) # the branch that deploys
rev= binary=
if [ "${1:-}" = --binary ]; then
binary=${2:?usage: pre-receive --binary FILE}
elif [ $# -gt 0 ]; then
rev=$(git rev-parse --verify "$1^{commit}")
else
while read -r _ new ref; do
[ "$ref" = "refs/heads/$branch" ] || continue
case $new in
*[!0]*) rev=$new ;;
*) echo "==> Refusing to delete $branch: it's what deploys."; exit 1 ;;
esac
done
[ -n "$rev" ] || exit 0 # other branches are kept, not deployed
fi
exec 9>"$HOME/.deploy.lock" && flock 9 # one deploy at a time
release=$HOME/releases/$(date -u +%Y%m%d-%H%M%S)-${rev:0:7}${binary:+binary}
previous=$(readlink "$HOME/app" || true)
fail() { # put the previous release back and reject the push
trap - ERR
echo "==> Deploy FAILED${1:+: $1}. Still running: ${previous:-nothing}"
if [ -n "$previous" ] && [ "$(readlink "$HOME/app")" != "$previous" ]; then
ln -sfn "$previous" "$HOME/app.new" && mv -T "$HOME/app.new" "$HOME/app"
sudo systemctl restart app
fi
rm -rf "$release"
exit 1
}
trap fail ERR
mkdir -p "$release/bin"
if [ -n "$binary" ]; then
echo "==> Installing a prebuilt binary in $release"
if [ "$binary" = - ]; then cat > "$release/bin/app"; else cp "$binary" "$release/bin/app"; fi
chmod +x "$release/bin/app"
else
echo "==> Building ${rev:0:7} in $release"
git archive "$rev" | tar -x -C "$release"
fi
unset GIT_DIR GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_QUARANTINE_PATH
cd "$release"
set -a; . "$HOME/app.env"; set +a
[ -n "$binary" ] || go build -trimpath -o bin/app "${MAIN_PACKAGE:-.}"
"$HOME/bin/app-backup" deploy >/dev/null # the new version may migrate when it starts
if [ -n "${MIGRATE_COMMAND:-}" ]; then
bash -c "$MIGRATE_COMMAND"
fi
echo "==> Starting the new version"
ln -sfn "$release" "$HOME/app.new" && mv -T "$HOME/app.new" "$HOME/app"
sudo systemctl restart app
deadline=$((SECONDS + 60)) # wait up to a minute for an answer
while [ $SECONDS -lt $deadline ]; do
code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' http://localhost:8000/ || true)
case $code in 000|5??) sleep 1 ;; *) break ;; esac
done
case $code in
000|5??) fail "the new version answers HTTP $code (journalctl -u app -n 50)" ;;
esac
trap - ERR
ls -1d "$HOME"/releases/*/ | head -n -3 | xargs -r rm -rf # keep 3
echo "==> Deployed ${rev:0:7}${binary:+a prebuilt binary}: HTTP $code on port 8000"
EOF
chmod +x ~/app.git/hooks/pre-receive
The hook builds MAIN_PACKAGE from ~/app.env into the release's
bin/app, the first time downloading modules into ~/go/pkg/mod, where
later builds find them. It takes a deploy backup, runs
MIGRATE_COMMAND if you set one, and counts any answer on port 8000 below
500 as up.
Start a new app#
Skip to Deploy if you brought a repository. Otherwise, make a module with the SQLite driver:
rm -rf /tmp/new-app && mkdir -p /tmp/new-app/migrations /tmp/new-app/templates /tmp/new-app/static
cd /tmp/new-app
git init -q -b main
go mod init "$(hostname)"
go get modernc.org/sqlite@latest
printf '# SQLite databases (on the VM, data lives in ~/data)\n*.db\n*.db-*\n# Builds\n/app-linux\n' > .gitignore
echo 'DATABASE_URL=file:/home/exedev/data/app.db' >> ~/app.env
The server, in main.go. Its templates, static files, and migrations are
embedded in the binary; it migrates the database on start, signs
messages with the email exe.dev's proxy sends, and shuts down gracefully
on SIGTERM:
cat > /tmp/new-app/main.go <<'EOF'
// A small Go web app: a guestbook in SQLite, signed with exe.dev.
package main
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"path"
"strconv"
"strings"
"syscall"
"time"
_ "modernc.org/sqlite" // SQLite in pure Go: no cgo, so it cross-compiles
)
// Compiled into the binary, so a release needs nothing else.
//
//go:embed templates static migrations
var files embed.FS
var page = template.Must(template.ParseFS(files, "templates/*.html"))
type message struct{ Author, Body string }
func main() {
db, err := openDB(env("DATABASE_URL", "file:app.db"))
if err != nil {
fatal("database", err)
}
defer db.Close()
static, _ := fs.Sub(files, "static")
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(), "SELECT author, body FROM messages ORDER BY id DESC LIMIT 50")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var msgs []message
for rows.Next() {
var m message
if err := rows.Scan(&m.Author, &m.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
msgs = append(msgs, m)
}
page.ExecuteTemplate(w, "index.html", msgs)
})
mux.HandleFunc("POST /sign", func(w http.ResponseWriter, r *http.Request) {
body := strings.TrimSpace(r.FormValue("body"))
if body == "" || len(body) > 280 {
http.Error(w, "say something, in 280 bytes or fewer", http.StatusBadRequest)
return
}
// exe.dev's proxy tells the app who is signed in.
author := "a visitor"
if email := r.Header.Get("X-ExeDev-Email"); email != "" {
author, _, _ = strings.Cut(email, "@")
}
if _, err := db.ExecContext(r.Context(), "INSERT INTO messages (author, body) VALUES (?, ?)", author, body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
})
srv := &http.Server{
Addr: ":" + env("PORT", "8000"),
// Rejects cross-site form posts (CSRF), using the browser's Sec-Fetch-Site and Origin headers.
Handler: http.NewCrossOriginProtection().Handler(mux),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
fatal("serve", err)
}
}()
slog.Info("listening", "addr", srv.Addr)
// systemctl stop and restart send SIGTERM: stop accepting connections,
// let requests in flight finish (up to 20 seconds), then exit.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
slog.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("shutdown", "err", err)
}
}
// openDB opens SQLite and applies new migrations. WAL lets requests read
// while another writes; busy_timeout makes writers wait their turn.
func openDB(url string) (*sql.DB, error) {
sep := "?"
if strings.Contains(url, "?") {
sep = "&"
}
db, err := sql.Open("sqlite", url+sep+"_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
return db, migrate(db)
}
// migrate runs migrations/NNNN_name.sql files newer than the database's
// user_version, in order, each in a transaction that records its number.
func migrate(db *sql.DB) error {
var version int
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
return err
}
names, _ := fs.Glob(files, "migrations/*.sql")
for _, name := range names {
n, err := strconv.Atoi(strings.SplitN(path.Base(name), "_", 2)[0])
if err != nil {
return fmt.Errorf("%s: name migrations NNNN_name.sql", name)
}
if n <= version {
continue
}
stmts, _ := files.ReadFile(name)
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(stmts)); err != nil {
tx.Rollback()
return fmt.Errorf("%s: %w", name, err)
}
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", n)); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
slog.Info("migrated", "file", name)
}
return nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func fatal(what string, err error) {
slog.Error(what, "err", err)
os.Exit(1)
}
EOF
The first migration, the page, and its stylesheet:
cd /tmp/new-app
cat > migrations/0001_messages.sql <<'EOF'
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
EOF
cat > templates/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guestbook</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<h1>Guestbook</h1>
<form method="post" action="/sign">
<input name="body" required maxlength="280" placeholder="Say hello">
<button>Sign</button>
</form>
<ul>
{{range .}} <li><b>{{.Author}}</b>: {{.Body}}</li>
{{else}} <li>No messages yet. Be the first.</li>
{{end}}</ul>
</main>
</body>
</html>
EOF
cat > static/style.css <<'EOF'
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; }
main { max-width: 36rem; margin: 3rem auto; padding: 0 1rem; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .5rem; }
button { padding: .5rem 1rem; }
ul { padding: 0; list-style: none; }
EOF
Check that it builds and vets cleanly, then commit and push to the VM's repository. The push deploys:
cd /tmp/new-app
go mod tidy
go vet ./...
git add .gitignore go.mod go.sum main.go migrations templates static
git -c user.name="$(hostname)" -c user.email="exedev@$(hostname).exe.xyz" commit -qm "A guestbook: net/http and SQLite"
git push ~/app.git HEAD:main
The output ends with ==> Deployed and a commit. The code now lives in
~/app.git (the owner clones it from there), so remove the scratch copy:
cd ~ && rm -rf /tmp/new-app
Deploy and check#
If you brought a repository, set MAIN_PACKAGE in ~/app.env if its
main package isn't at the top (such as ./cmd/server), and add the
settings it reads (see its README or .env.example). Make it listen on
port 8000 and keep SQLite files in ~/data. Then deploy its default
branch:
~/app.git/hooks/pre-receive HEAD
(A new app deployed when you pushed it.) Check, for either kind:
systemctl is-enabled app; systemctl is-active app
readlink ~/app
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/
Expect enabled, active, the release directory, and 200 (or your home
page's usual status, such as a 302 to a login page). For a new app,
check that the guestbook reads the database and serves its stylesheet:
curl -s http://localhost:8000/ | grep -o 'No messages yet'
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:8000/static/style.css
sqlite3 ~/data/app.db 'PRAGMA user_version' .tables
Expect No messages yet, 200 text/css; charset=utf-8, 1, and
messages.
Check the backup#
Take the first backup by hand, then open it in a scratch directory, never
over ~/data, to check that it holds what a restore needs:
latest=$(~/bin/app-backup manual)
rm -rf /tmp/restore-check && mkdir /tmp/restore-check
tar -xzf "$latest" -C /tmp/restore-check
ls -A /tmp/restore-check /tmp/restore-check/data
git -C ~/app.git bundle verify /tmp/restore-check/app.bundle
find /tmp/restore-check/data -type f \( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) |
while read -r db; do echo "$db: $(sqlite3 "$db" 'PRAGMA integrity_check')"; done
rm -rf /tmp/restore-check
Expect app.bundle, app.env, and data; The bundle records a complete history; and ok for each database (a new app has app.db).
Setup is done. Continue with Open your app.
Restore a backup
On the same VM#
On the VM, pick the archive (ls -lt ~/backups), unpack it, and swap
its data in while the app is stopped. The current data is kept aside:
backup=~/backups/app-nightly-20261031-030512.tar.gz # the one you want
rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf "$backup" -C /tmp/restore
sudo systemctl stop app
mv ~/data ~/data.before-restore-$(date +%s)
cp -a /tmp/restore/data ~/data
sudo systemctl start app
Open the app and check your data. ~/app.env is in /tmp/restore too, if
you need an old setting back. Delete ~/data.before-restore-* when you're
sure. If the backup is older than the code's migrations, the app applies
the missing ones when it starts.
On a new VM#
Create the VM and follow Set up by hand up to, but not
including, "Get the code". Copy the archive over from your computer
(scp app-nightly-20261031-030512.tar.gz my-go.exe.xyz:), then on the VM:
rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf ~/app-nightly-20261031-030512.tar.gz -C /tmp/restore
git clone --bare /tmp/restore/app.bundle ~/app.git && git -C ~/app.git remote remove origin
install -m 600 /tmp/restore/app.env ~/app.env
rm -rf ~/data && cp -a /tmp/restore/data ~/data
Then follow Add the deploy hook and deploy with
~/app.git/hooks/pre-receive HEAD. On your computer, point the exe
remote at the new VM: git remote set-url exe <new-vm>.exe.xyz:app.git.
Update Go and your dependencies
Both travel through Git, like any other change. On your computer:
- Dependencies:
go get -u ./...(orgo get modernc.org/sqlite@latestfor one), thengo mod tidy, run your tests, commitgo.modandgo.sum, and push. - Go:
go get go@latestraises thegoline ingo.modto the newest release (or name one:go get go@1.27.3). Commit and push: if it's newer than the VM's Go, the build downloads that version first, into~/go/pkg/mod.
The Go in /usr/local/go comes with the VM's image and only matters for
builds whose go.mod asks for something older. sudo apt-get update && sudo apt-get upgrade -y keeps Ubuntu current. Old toolchains and modules
pile up in ~/go/pkg/mod; go clean -modcache empties it, and the next
build downloads what it needs.
Something not working?
On the VM, the app's logs:
journalctl -u app -n 100 --no-pager
git pushsaysDeploy FAILEDandpre-receive hook declined. The lines above it are the error, usually the same onego buildgives on your computer. The running version didn't change; fix it and push again.missing go.sum entryorupdates to go.mod needed. Rungo mod tidyon your computer and commitgo.modandgo.sum.no Go files in /home/exedev/releases/.... The main package isn't at the top of the repository: setMAIN_PACKAGEin~/app.env.HTTP 000in the failed deploy. The new version exited or didn't listen on port 8000;journalctl -u app -n 50says why, often a missing setting.cross-origin request detected(403).http.CrossOriginProtectionrefused a post from another site; trust one withAddTrustedOrigin.- Disk filling up.
du -sh ~/releases/* ~/backups ~/go ~/.cache/go-build; the hook keeps three releases, andgo clean -cache -modcacheempties Go's caches.
Frequently asked questions#
Should I build on the VM or on my computer?
On the VM, to start with: git push is the whole deploy, cached rebuilds take seconds, and what runs is always a commit in ~/app.git. To ship exactly the binary you tested, or one CI built, see Build on your computer.
Does a deploy drop requests?
Barely. The starter finishes requests in flight when it gets SIGTERM, and a Go binary starts in milliseconds, so only requests that arrive in that gap get an error. If even that matters, systemd socket activation can hold port 8000 open across restarts.
Can I use PostgreSQL instead of SQLite?
Yes. Set up PostgreSQL on the same VM; it puts a DATABASE_URL in ~/app.env, which pgx reads directly. SQLite is the simpler choice until many processes write at once: it's a file on the VM's disk, backed up every night.
Do I need Docker?
No. A Go program is one binary, and systemd starts it at boot, restarts it if it crashes, and collects its logs. If your project ships a docker-compose.yml, see Docker Compose.
Does it sleep, or have cold starts?
No. The VM is always on, so goroutines and WebSocket connections keep running. You pay for the VM, not per request; see exe.dev plans.
Updated Oct 31, 2026 · tested end to end Oct 31, 2026 · Markdown · manifest.json · Something wrong? Tell us