---
title: "Deploy a Go web app"
description: "Deploy a Go web app to an exe.dev VM: git push builds it, systemd runs the binary, SQLite sits on a persistent disk, and exe.dev adds HTTPS."
url: https://exe.dev/deploy/go
category: "Frameworks"
updated: 2026-10-31
---

# Deploy a Go web app

One Go binary on a VM of its own. git push to deploy.

- **Software:** Go (https://go.dev)
- **Source:** https://github.com/golang/go
- **License:** BSD-3-Clause
- **Time:** 10 minutes
- **Example VM name:** `my-go` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/go/manifest.json

## 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.

Running on the VM:

- **Your Go app**: one binary, built from your repository, as app.service under systemd, port 8000, data in `~/app → the current release in ~/releases`
- **Go 1.27**: preinstalled; go.mod picks the version each build uses
- **git push deploys**: A pre-receive hook in ~/app.git builds, backs up, switches, and checks
- **SQLite**: modernc.org/sqlite (pure Go, no cgo) in new apps; settings in ~/app.env, data in `~/data`
- **Nightly backup**: app-backup.timer; data, settings, and the repository, data in `~/backups`

How it is reached:

- https://my-go.exe.xyz/ (private): Your app, until you make it public
- my-go.exe.xyz:app.git (private): `git push exe main` deploys

## Deploy with Shelley

Open https://exe.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Go%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fgo.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20REPO%3A%20the%20GitHub%20repository%20to%20deploy%2C%20as%20owner%2Fname%20or%20a%20URL%2C%20or%20%60new%60%20to%20start%20a%20fresh%20Go%20app%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-go.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-go.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20The%20app%27s%20binary%20answering%20on%20port%208000%20as%20app.service%2C%20enabled%20to%20come%20back%20after%20a%20reboot.%0A-%20A%20~%2Fapp.git%20repository%20whose%20pre-receive%20hook%20builds%20each%20push%20to%20main%20into%20a%20new%20release%20and%20rejects%20the%20push%20if%20the%20deploy%20fails.%0A-%20Settings%20and%20secrets%20in%20~%2Fapp.env%20%28mode%20600%29%2C%20read%20by%20the%20build%20and%20by%20the%20server.%0A-%20Data%20in%20~%2Fdata%2C%20backed%20up%20nightly%20by%20app-backup.timer%2C%20with%20the%20newest%20backup%20opened%20and%20checked%20in%20a%20scratch%20directory.%0A-%20With%20REPO%3Dnew%2C%20a%20net%2Fhttp%20guestbook%20that%20saves%20messages%20to%20SQLite%2C%20with%20its%20templates%20and%20static%20files%20embedded%20in%20the%20binary.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details.%20Follow%20%22Set%20up%20by%0Ahand%22%20from%20%22Check%20Go%22%20to%20the%20end%20of%20it%2C%20as%20exedev%3B%20the%20walkthrough%0Aabove%20it%20is%20for%20the%20owner%20afterwards.%20Do%20not%20run%20Restore%20or%20Update%2C%20and%0Ado%20not%20reboot.%20Leave%20public%20sharing%20off.%0AREPO%20picks%20one%20path%20in%20%22Get%20the%20code%22%3A%20%60new%60%20starts%20a%20fresh%20app%3B%0Aanything%20else%20is%20a%20GitHub%20repository%20%28turn%20URLs%20into%20owner%2Fname%29.%20Never%0Acommit%20to%20a%20repository%20you%20cloned%3A%20its%20settings%20go%20in%20~%2Fapp.env.%20If%20a%0Aclone%20fails%2C%20follow%20the%20private-repository%20steps%2C%20and%20if%20that%20fails%0Atoo%2C%20give%20the%20owner%20the%20integration%20command%20and%20wait%20for%20them.%0AFor%20a%20cloned%20app%2C%20find%20its%20main%20package%20and%20set%20MAIN_PACKAGE%3B%20fill%0A~%2Fapp.env%20from%20its%20README%20or%20.env.example%3A%20make%20it%20listen%20on%20port%208000%2C%0Agenerate%20secrets%20with%20openssl%2C%20ask%20the%20owner%20for%20keys%20to%20outside%0Aservices%2C%20and%20never%20invent%20values.%20If%20a%20build%20fails%2C%20read%20its%20output%2C%0Afix%20what%20you%20can%20without%20editing%20the%20code%2C%20and%20redeploy%20with%0A~%2Fapp.git%2Fhooks%2Fpre-receive%20HEAD.%0AFinish%20with%20the%20app%27s%20URL%20and%20the%20git%20commands%20the%20owner%20runs%20on%20their%0Aown%20computer%2C%20from%20%22Deploy%20with%20git%20push%22%3A%20%60git%20clone%20-o%20exe%60%20for%20a%20new%0Aapp%2C%20%60git%20remote%20add%20exe%60%20for%20a%20GitHub%20repository%2C%20then%20%60git%20push%20exe%0Amain%60%20%28or%20their%20default%20branch%29.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open.%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/go/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

Go 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.

## 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:

```bash
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:

```bash
git remote add exe my-go.exe.xyz:app.git
```

Then change something, commit, and push:

```bash
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](/docs/faq/ssh-destination)).

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:

```bash
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:

```bash
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](https://pkg.go.dev/modernc.org/sqlite) driver. It
migrates itself on start, running new files from `migrations/` in order.
To add a column, **on your computer**:

```bash
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](/deploy/postgres#run-it-next-to-your-app).
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](/docs/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.Handler`s 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](/docs/sharing)
can open it, after signing in to exe.dev. When you're ready for the world,
**on your computer**:

```bash
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](/docs/cnames)). 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](/docs/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:

```go
// 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**:

```bash
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](/deploy/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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Set up by hand</summary>

### Create the VM

On your computer (pick another name if `my-go` is taken):

```bash
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:

```bash
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:

```bash
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/`:

```bash
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`.

```bash
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:

```bash
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`.

```bash
git init --bare -b main ~/app.git
```

**Bring your GitHub repository**: a copy of it. A public repository
clones directly; replace `OWNER/NAME`:

```bash
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](https://exe.dev/integrations)) and attach the repository to this VM.
On your computer:

```bash
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](/docs/integrations-github)):

```bash
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.

```bash
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](#deploy-and-check) if you brought a repository.
Otherwise, make a module with the SQLite driver:

```bash
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`:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
~/app.git/hooks/pre-receive HEAD
```

(A new app deployed when you pushed it.) Check, for either kind:

```bash
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:

```bash
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:

```bash
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](#open-your-app).

</details>

<details id="restore" class="dp-details">
<summary>Restore a backup</summary>

### 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:

```bash
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](#create-the-vm) 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:

```bash
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](#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`.

</details>

<details id="update" class="dp-details">
<summary>Update Go and your dependencies</summary>

Both travel through Git, like any other change. On your computer:

- **Dependencies:** `go get -u ./...` (or `go get modernc.org/sqlite@latest`
  for one), then `go mod tidy`, run your tests, commit `go.mod` and
  `go.sum`, and push.
- **Go:** `go get go@latest` raises the `go` line in `go.mod` to 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.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

On the VM, the app's logs:

```bash
journalctl -u app -n 100 --no-pager
```

- **`git push` says `Deploy FAILED` and `pre-receive hook declined`.**
  The lines above it are the error, usually the same one `go build`
  gives on your computer. The running version didn't change; fix it and
  push again.
- **`missing go.sum entry` or `updates to go.mod needed`.** Run `go mod
  tidy` on your computer and commit `go.mod` and `go.sum`.
- **`no Go files in /home/exedev/releases/...`.** The main package isn't
  at the top of the repository: set `MAIN_PACKAGE` in `~/app.env`.
- **`HTTP 000` in the failed deploy.** The new version exited or didn't
  listen on port 8000; `journalctl -u app -n 50` says why, often a
  missing setting.
- **`cross-origin request detected` (403).** `http.CrossOriginProtection`
  refused a post from another site; trust one with `AddTrustedOrigin`.
- **Disk filling up.** `du -sh ~/releases/* ~/backups ~/go ~/.cache/go-build`;
  the hook keeps three releases, and `go clean -cache -modcache` empties
  Go's caches.

</details>

## 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](#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](https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html) can hold port 8000 open across restarts.

### Can I use PostgreSQL instead of SQLite?

Yes. Set up [PostgreSQL](/deploy/postgres#run-it-next-to-your-app) on the same VM; it puts a `DATABASE_URL` in `~/app.env`, which [pgx](https://github.com/jackc/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](/deploy/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](https://exe.dev/pricing).


## Related

- [Deploy a Node.js app](https://exe.dev/deploy/nodejs.md): Your Express app on a server of its own. git push to deploy.
- [Deploy a FastAPI app](https://exe.dev/deploy/fastapi.md): Your API on a VM of its own. Push to deploy; the data stays put.
- [Deploy Next.js on your own server](https://exe.dev/deploy/nextjs.md): Your Next.js app on a server of its own. git push to deploy.
- [Self-host PostgreSQL](https://exe.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Dev, Test, Prod: Choose One, Two, or Three](https://exe.dev/deploy/dev-test-prod.md): You can build, test, and deploy on exe.dev.
- [Run Claude Code on a remote server](https://exe.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
