Skip to content

Move a Lovable app and its data to exe.dev

Your Lovable app, its database, and its users, on a server of your own.

Deploy on exe.dev

Shelley handles the setup. About 30 minutes.

Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/migrate-lovable/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

Lovable runs your app on Lovable Cloud: hosting at lovable.app and a Supabase backend with your database, users, and files. This recipe moves all of it to an exe.dev VM, which builds the app from the GitHub repository Lovable syncs to and runs Supabase itself, so your code doesn't change. Your data comes across in Lovable's own export, passwords included.

It's for apps on TanStack Start, Lovable's stack since May 13, 2026 (older apps). First connect the project to GitHub in Lovable (Project settings → Git → GitHub → Connect), and link your GitHub account on exe.dev's Integrations page, so the VM can read the repository without a token.

What you get#

  • Supabase running in Docker in ~/supabase with keys of its own, restarting after a reboot, and Caddy on port 8000 serving its APIs and the app at https://my-lovable.exe.xyz/.
  • ~/app.git cloned through the GitHub integration, and ~/bin/app-deploy building the newest commit as a Node.js server, applying new files in supabase/migrations, switching over, and keeping the previous release if anything fails; app-deploy.timer runs it every minute.
  • The app answering on port 8000 as app.service, on a database with the app's tables and no data yet.
  • ~/bin/app-restore, as published with the recipe, ready to load a Lovable Cloud export; ~/bin/app-backup with app-backup.timer enabled, and a first backup in ~/backups.
  • The output of ~/bin/app-check, saying what in this app still depends on Lovable.
What's running, and where
Private https://my-lovable.exe.xyz/ Your app, until you make it public
Private https://my-lovable.exe.xyz:8001/ Supabase Studio, behind a password as well
my-lovable.exe.xyz exe.dev VM
Caddy The front door on port 8000; Supabase's APIs and the app at one address :8000
Your Lovable app TanStack Start's Node.js server, built from GitHub, as app.service :3000~/app → the current release in ~/releases
Supabase Database, sign-in, storage, and realtime in Docker, in place of Lovable Cloud; Studio on port 8001 :8001~/supabase/volumes; its settings and keys in ~/supabase/.env
Node.js 24 and Bun Bun installs from Lovable's bun.lock; Node.js builds and runs
Deploys and backups app-deploy.timer builds each new commit and applies its migrations; app-backup.timer saves the database and files nightly ~/app.git, ~/backups

Deploy with Shelley#

Click Deploy and Shelley will set up Apps from Lovable 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 Lovable syncs your project to, as owner/name (Lovable shows it under Project settings → Git → GitHub)
Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/migrate-lovable/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin
What Shelley is told
REPO=<fill-this-in>

Set up Apps from Lovable on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/migrate-lovable.md from top to bottom.

The values at the top of this message are mine:
- REPO: the GitHub repository Lovable syncs your project to, as owner/name (Lovable shows it under Project settings → Git → GitHub)
Ask me for any that still say <fill-this-in> before you need them.

The recipe's example VM name is my-lovable. This VM's name is the output of `hostname`: use it wherever the recipe writes my-lovable. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- Supabase running in Docker in `~/supabase` with keys of its own, restarting after a reboot, and Caddy on port 8000 serving its APIs and the app at https://my-lovable.exe.xyz/.
- `~/app.git` cloned through the GitHub integration, and `~/bin/app-deploy` building the newest commit as a Node.js server, applying new files in `supabase/migrations`, switching over, and keeping the previous release if anything fails; `app-deploy.timer` runs it every minute.
- The app answering on port 8000 as `app.service`, on a database with the app's tables and no data yet.
- `~/bin/app-restore`, as published with the recipe, ready to load a Lovable Cloud export; `~/bin/app-backup` with `app-backup.timer` enabled, and a first backup in `~/backups`.
- The output of `~/bin/app-check`, saying what in this app still depends on Lovable.

Read the whole recipe, including the closed details. Follow "Set up by
hand" from "Install Node.js, Bun, Caddy, and PostgreSQL's tools" to the
end of it, as exedev; the walkthrough above it is for the owner
afterwards. Do not run "Move to a new VM" or "Update", do not reboot,
and leave public sharing off. The scripts and units were tested as
written: use them unchanged, check that yours match the recipe, and
leave no test files behind.
REPO is a GitHub repository; turn a URL into owner/name. Clone it
through the GitHub integration. If that fails with "not found" or a 403,
and https://github.com/OWNER/NAME is public, clone that instead;
otherwise give the owner the integration command from "Get the code",
with this VM's name, and wait for them. Install everything else meanwhile.
Never commit or push to the repository: Lovable writes to it.
If the first deploy stops because this isn't a TanStack Start app, say
so and point the owner to the FAQ on older apps; leave the rest set up.
Don't wait for the Lovable export: the owner downloads it. Don't run
app-restore until they ask you to.
Keep keys and passwords out of your replies; say which file holds them.
In your report, say what ~/bin/app-check found, in plain words, with the
section of this recipe that deals with each item. Then give the owner
the next step: export from Lovable as "Bring your data over" says, the
scp command with this VM's name, and to ask you to restore it.

Finish with the URLs I should open.

Also at /deploy/migrate-lovable/prompt.txt. The recipe it follows is /deploy/migrate-lovable.md.

How the move works#

  1. Deploy sets up the VM: Supabase, and your app with its tables but no data. Lovable keeps serving meanwhile.
  2. Bring your data over from a Lovable export and check the app. Rehearse as often as you like.
  3. Sign-in: passwords work at once; Shelley can set up Google sign-in and email.
  4. Make the move: a final export, your domain on the VM, and lovable.app unpublished.

Open your app#

Open https://my-lovable.exe.xyz/; it's private to you until you make it public. Supabase Studio, for browsing the database, users, and files, is at https://my-lovable.exe.xyz:8001/, behind a password that grep DASHBOARD ~/supabase/.env shows on the VM.

Bring your data over#

In Lovable, export the database: More → Cloud → Overview → Advanced settings → Export project data, then Export on the Database card. Lovable emails you when the .backup file (maybe in a .zip) is ready. It lists your stored files but doesn't hold them: in More → Cloud → Storage, open each bucket, select everything, and Download, naming each zip after its bucket (avatars.zip).

On your computer, copy them to the VM, with your file names:

ssh my-lovable.exe.xyz mkdir -p lovable
scp ~/Downloads/*.backup ~/Downloads/avatars.zip my-lovable.exe.xyz:lovable/

On the VM, restore, or ask Shelley: "Restore the Lovable export in ~/lovable and tell me what didn't come across."

~/bin/app-restore ~/lovable/project-export.backup

After asking, it replaces the VM's data with the export's in a minute or so, compares row counts, which should match, and lists what it left out, such as scheduled jobs. Sign in with your own account and check your records, uploads, and server features. Your users stay on Lovable until you make the move.

Sign-in#

Accounts came across with their password hashes and linked identities; sessions didn't, so everyone signs in once more.

  • Email and password sign-in works already.
  • Sign-in emails (confirmations, password resets, magic links) need your email provider's SMTP settings, such as Resend's. Until then, sign-ups fail with "Error sending confirmation email".
  • Google, Apple, or Microsoft need an OAuth client of your own. Caddy already sends the app's buttons to Supabase instead of Lovable, so the code stays the same, and Google users land in their old accounts.

Shelley can move sign-in over for you: it checks which methods the app uses, walks you through the provider's console, configures Supabase, and tests a sign-in with you. Try:

Set up sign-in like it was on Lovable: walk me through creating a Google
OAuth client, then set it up in Supabase. Then set up email sending with
my Resend account.

Or do it by hand. For an internal tool, you can also keep the VM private and share it with your team.

Keep building#

Keep using Lovable. Each change is a commit on main; within a minute the VM builds it, applies new files in supabase/migrations, and switches over, keeping the old version if anything fails. journalctl -u app-deploy -f shows it. Lovable's preview stays on Lovable Cloud, now a test copy: data there and on the VM never mix.

Warning

Every Lovable edit goes live, migrations included. Make bigger changes in a Lovable draft, or deploy only when you say so: sudo systemctl disable --now app-deploy.timer, then ~/bin/app-deploy when you're ready.

To roll back, restore an earlier version in Lovable's history (migrations aren't undone). To leave Lovable entirely, untick Read-only for lovable-repo on the Integrations page and ask Shelley for changes instead; it pushes, and the VM deploys.

AI features and connectors#

Lovable AI, connectors such as Stripe or Twilio, and Lovable's email all need LOVABLE_API_KEY, which exists only on Lovable's hosting. On the VM they fail with LOVABLE_API_KEY is not configured; ~/bin/app-check lists the files.

For AI, use exe.dev's LLM integration: OpenAI-compatible like Lovable's gateway, with no key, billed to your exe.dev account. Ask Lovable (or Shelley):

Make the AI gateway configurable. In server code, use process.env.AI_BASE_URL
as the base URL when it's set, otherwise https://ai.gateway.lovable.dev/v1;
use process.env.AI_MODEL as the model when it's set; and only require and
send LOVABLE_API_KEY when AI_BASE_URL is not set.

Once that's deployed, on the VM:

cat >> ~/app.env <<'EOF'
AI_BASE_URL=https://llm.int.exe.xyz/v1
AI_MODEL=gpt-5.4-mini
EOF
sudo systemctl restart app

curl -s https://llm.int.exe.xyz/v1/models lists the models. For connectors and email, ask for the same pattern ("When STRIPE_SECRET_KEY is set, call Stripe directly"), then put your key in ~/app.env, or keep it off the VM with an exe.dev integration.

Settings and secrets#

Lovable's secrets (More → Cloud → Secrets) can't be read back: get each key again from its service, add it to ~/app.env under the same name, and sudo systemctl restart app (~/bin/app-deploy --force for a VITE_ one, which is built into the page). ~/bin/app-check lists what the server reads that isn't set. ~/app.env overrides the repository's .env, which stays pointed at Lovable Cloud for Lovable's preview.

Scheduled jobs#

Jobs from More → Cloud → Jobs come across but stay off, since they call lovable.app with a secret Lovable keeps; app-restore lists them. Ask Shelley to "recreate the scheduled jobs app-restore listed, against this VM". One that only runs SQL can go back into pg_cron from Studio's SQL editor; one that calls a route in your app becomes a systemd timer.

A job as a systemd timer

With your route and schedule, on the VM:

grep -q '^LOVABLE_CRON_SECRET=' ~/app.env || echo "LOVABLE_CRON_SECRET=$(openssl rand -hex 32)" >> ~/app.env
sudo systemctl restart app
sudo tee /etc/systemd/system/app-job-digest.service >/dev/null <<'EOF'
[Unit]
Description=Call the app's daily digest route

[Service]
Type=oneshot
User=exedev
EnvironmentFile=/home/exedev/app.env
ExecStart=/usr/bin/curl -fsS -X POST -H "Authorization: Bearer ${LOVABLE_CRON_SECRET}" http://localhost:3000/api/public/cron/digest
EOF
sudo tee /etc/systemd/system/app-job-digest.timer >/dev/null <<'EOF'
[Timer]
OnCalendar=*-*-* 07:00:00 UTC
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-job-digest.timer

sudo systemctl start app-job-digest runs it now; journalctl -u app-job-digest shows how it went.

Make the move#

Lower your DNS TTL to 300 seconds a day ahead. The app is down from step 1 to step 3, typically a quarter of an hour.

  1. Stop changes on Lovable: Project settings → Unpublish project, and disconnect your domain under Project settings → Domains.

  2. Export and restore again, as in Bring your data over. Lovable allows one export a day, so rehearse at least a day before.

  3. Go public, on your computer. For your own domain, first point a CNAME at my-lovable.exe.xyz (custom domains):

    ssh exe.dev share set-public my-lovable
    ssh exe.dev domain add my-lovable app.example.com
    
  4. Point sign-in at the domain, on the VM, with yours:

    domain=app.example.com
    sed -i "s|^VITE_SUPABASE_URL=.*|VITE_SUPABASE_URL=https://$domain|" ~/app.env
    sed -i -e "s|^SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=https://$domain|" \
      -e "s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=https://$domain/auth/v1|" \
      -e "s|^SITE_URL=.*|SITE_URL=https://$domain|" \
      -e "s|^ADDITIONAL_REDIRECT_URLS=.*|ADDITIONAL_REDIRECT_URLS=https://$domain/**,https://$(hostname).exe.xyz/**|" ~/supabase/.env
    (cd ~/supabase && sh run.sh start)
    ~/bin/app-deploy --force
    

    With Google sign-in, also add https://app.example.com/auth/v1/callback to your OAuth client.

Without a domain, skip domain add and step 4. Keep Lovable Cloud until you're sure: removing it deletes Lovable's copy and its exports.

Backups#

app-backup.timer saves the database and files to ~/backups every night at 03:00, keeping a week. To keep a copy elsewhere, on your computer: scp -r my-lovable.exe.xyz:backups ./my-lovable-backups. To go back to a night, on the VM: ~/bin/app-restore ~/backups/2026-10-20.backup ~/backups/files. Keep ~/supabase/.env and ~/app.env safe as well; they hold the keys and secrets.

Ask Shelley#

Shelley, at https://my-lovable.shelley.exe.xyz/, knows this setup. Try: "Set up Google sign-in", "Why did the last deploy fail?", "Which secrets does the server need?", or "Make the AI features use exe.dev's LLM gateway".

The nuts and bolts#

What Deploy runs, for doing it by hand, and the commands for later.

Set up by hand

Create the VM#

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

ssh exe.dev new --name=my-lovable
ssh my-lovable.exe.xyz

Everything else runs on the VM, as exedev.

Install Node.js, Bun, Caddy, and PostgreSQL's tools#

Node.js 24 from NodeSource (the image's is too old for Vite), Bun for Lovable's bun.lock, Caddy for the front door, and PostgreSQL 18's pg_restore, which reads Lovable's export:

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs caddy postgresql-common jq unzip rsync
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get install -y postgresql-client-18
sudo npm install -g bun
sudo systemctl disable --now caddy
node --version; bun --version; caddy version; pg_restore --version

Expect v24., Bun 1.4 or later, Caddy v2., and pg_restore (PostgreSQL) 18.. Caddy stays off until it's configured.

Install Supabase#

Supabase's self-hosting kit, in Docker Compose. setup.sh puts a release in ~/supabase and generates its keys into ~/supabase/.env; the sed sets the VM's address and moves Supabase's gateway to port 8001, leaving 8000 to Caddy:

sudo systemctl enable --now docker.service
cd ~
curl -fsSL https://raw.githubusercontent.com/supabase/supabase/self-hosted/v0.8.3/docker/setup.sh -o /tmp/supabase-setup.sh
sh /tmp/supabase-setup.sh --skip-deps --ref self-hosted/v0.8.3 --project-dir supabase -y
cd ~/supabase
url=https://$(hostname).exe.xyz
sed -i \
  -e "s|^SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=$url|" \
  -e "s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=$url/auth/v1|" \
  -e "s|^SITE_URL=.*|SITE_URL=$url|" \
  -e "s|^ADDITIONAL_REDIRECT_URLS=.*|ADDITIONAL_REDIRECT_URLS=$url/**|" \
  -e "s|^API_GW_HTTP_PORT=.*|API_GW_HTTP_PORT=8001|" \
  -e "s|^KONG_HTTP_PORT=.*|KONG_HTTP_PORT=8001|" \
  -e "s|^KONG_HTTPS_PORT=.*|KONG_HTTPS_PORT=8444|" \
  .env
chmod 600 .env
sh run.sh start
docker ps --format '{{.Names}}: {{.Status}}'

The first start pulls about 5 GB of images. Expect a dozen supabase- containers, Up and (healthy); they come back after a reboot. Keep ~/supabase/.env safe: it holds the keys and passwords.

Get the code#

Attach the repository to the VM through exe.dev's GitHub integration, read-only, so no token lands on the VM and it can never push to Lovable's repository. On your computer, with your repository's owner and name:

ssh exe.dev integrations add github --name lovable-repo --repository OWNER/NAME --readonly --attach vm:my-lovable

Then, on the VM:

git clone --bare https://github.int.exe.xyz/OWNER/NAME.git ~/app.git
git -C ~/app.git symbolic-ref --short HEAD

The last line prints the branch that deploys, normally main. "Not found" or a 403 means the integration isn't attached yet; a public repository also clones from https://github.com/OWNER/NAME.git.

Settings and the service#

~/app.env holds the app's settings: Supabase's public address and key for the browser, the local gateway and service-role key for the server, and later your secrets:

mkdir -p ~/releases ~/bin
supa() { grep "^$1=" ~/supabase/.env | cut -d= -f2-; }
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 (VITE_* values end
# up in the browser's JavaScript) and by app.service. Overrides the
# repository's .env. After changing it: ~/bin/app-deploy --force

# The browser reaches Supabase through the app's own address,
VITE_SUPABASE_URL=https://$(hostname).exe.xyz
VITE_SUPABASE_PUBLISHABLE_KEY=$(supa SUPABASE_PUBLISHABLE_KEY)
VITE_SUPABASE_PROJECT_ID=$(hostname)
# and the server directly, on the VM.
SUPABASE_URL=http://localhost:8001
SUPABASE_PUBLISHABLE_KEY=$(supa SUPABASE_PUBLISHABLE_KEY)
SUPABASE_PROJECT_ID=$(hostname)
SUPABASE_SERVICE_ROLE_KEY=$(supa SUPABASE_SECRET_KEY)
EOF
fi
stat -c '%a %n' ~/app.env

Expect 600 /home/exedev/app.env. The app runs from ~/app, a link to the live release, on port 3000:

sudo tee /etc/systemd/system/app.service >/dev/null <<'EOF'
[Unit]
Description=The Lovable app (the current release in ~/app)
After=network-online.target docker.service
Wants=network-online.target

[Service]
User=exedev
WorkingDirectory=/home/exedev/app
Environment=PORT=3000 HOST=127.0.0.1 NODE_ENV=production
EnvironmentFile=/home/exedev/app.env
ExecStart=/usr/bin/node .output/server/index.mjs
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app.service

It starts with the first deploy.

The front door#

Caddy, on port 8000 behind exe.dev's proxy, sends Supabase's API paths to the gateway and the rest to the app, so the browser sees one address. It also hands /~oauth/initiate, where a Lovable app's Google, Apple, and Microsoft buttons go, to Supabase's sign-in:

sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
# The front door on port 8000: Supabase's APIs to its gateway on port 8001,
# everything else to the app on port 3000. One address for both, so the
# browser talks to Supabase without cross-site requests.
{
	admin off
	auto_https off
}

:8000 {
	@supabase path /auth/v1/* /rest/v1/* /storage/v1/* /realtime/v1/* /functions/v1/* /graphql/v1/*
	handle @supabase {
		reverse_proxy 127.0.0.1:8001
	}
	# Sign-in with Google, Apple, or Microsoft in a Lovable app starts at
	# /~oauth/initiate, which Lovable's hosting answers. Here, Supabase Auth
	# does, and sends the user back signed in.
	handle /~oauth/initiate {
		map {query.provider} {provider} {
			microsoft azure
			default {query.provider}
		}
		redir * /auth/v1/authorize?provider={provider}&redirect_to={query.redirect_uri}
	}
	handle {
		reverse_proxy 127.0.0.1:3000
	}
}
EOF
caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile
sudo systemctl enable caddy
sudo systemctl restart caddy

Expect Valid configuration.

The deploy script#

For each new commit, ~/bin/app-deploy builds in a fresh directory under ~/releases (Nitro's node-server preset makes a Node.js server instead of Lovable's Cloudflare Worker), applies new supabase/migrations files in order, each in a transaction, switches ~/app, and checks that the app answers. On any failure the previous release stays and that commit isn't retried. It keeps three releases.

cat > ~/bin/app-deploy <<'EOF'
#!/usr/bin/env bash
# Deploys the app from GitHub. app-deploy.timer runs this every minute; it
# does nothing unless the deploy branch has a new commit. By hand:
#   ~/bin/app-deploy           deploy the newest commit, if it isn't live
#   ~/bin/app-deploy --force   build and deploy it again (after editing ~/app.env)
#   ~/bin/app-deploy SHA       deploy that commit (stop app-deploy.timer first)
set -euo pipefail
exec 9>"$HOME/.deploy.lock" && flock 9  # one deploy (or restore) at a time
set -a; . "$HOME/app.env"; set +a
export GIT_DIR=$HOME/app.git
branch=${DEPLOY_BRANCH:-$(git symbolic-ref --short HEAD)}
git fetch -q origin "+refs/heads/$branch:refs/heads/$branch"
case ${1:-} in
  "") rev=$(git rev-parse "$branch"); force= ;;
  --force) rev=$(git rev-parse "$branch"); force=1 ;;
  *) rev=$(git rev-parse --verify "$1^{commit}"); force=1 ;;
esac
if [ -z "$force" ]; then
  [ "$rev" != "$(cat "$HOME/app/.rev" 2>/dev/null)" ] || exit 0       # live already
  [ "$rev" != "$(cat "$HOME/.deploy-failed" 2>/dev/null)" ] || exit 0  # failed before
fi

release=$HOME/releases/$(date -u +%Y%m%d-%H%M%S)-${rev:0:7}
previous=$(readlink "$HOME/app" || true)
fail() {  # put the previous release back, and remember the commit that failed
  trap - ERR
  echo "==> Deploy of ${rev:0:7} FAILED${1:+: $1}. Still running: ${previous:-nothing}"
  echo "$rev" > "$HOME/.deploy-failed"
  if [ -z "$previous" ]; then
    rm -f "$HOME/app" && sudo systemctl stop app
  elif [ "$(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

echo "==> Building ${rev:0:7} ($(git log -1 --format=%s "$rev")) in $release"
mkdir -p "$release"
git archive "$rev" | tar -x -C "$release"
echo "$rev" > "$release/.rev"
unset GIT_DIR
cd "$release"
grep -q '"@tanstack/react-start"' package.json ||
  fail "this isn't a TanStack Start app; see the recipe for older Lovable apps"
unset NODE_ENV  # the build needs devDependencies
export npm_config_update_notifier=false
# Lovable installs with Bun. If bun.lock lags behind package.json (after an
# edit on GitHub, say), resolve the difference as Lovable would.
if [ -f bun.lock ] || [ -f bun.lockb ]; then
  echo "==> Installing packages with Bun"
  bun install --frozen-lockfile --no-summary ||
    { echo "==> bun.lock is out of date; installing anyway"; bun install --no-summary; }
elif [ -f package-lock.json ]; then
  npm ci || { echo "==> package-lock.json is out of date; installing anyway"; npm install; }
else
  npm install
fi
# A Node.js server in .output, not the Cloudflare Worker Lovable deploys.
NITRO_PRESET=node-server npm run build
[ -f .output/server/index.mjs ] || fail "the build made no .output/server/index.mjs"
rm -rf node_modules  # the build output stands alone

# New files in supabase/migrations, in order, each in a transaction, as
# Supabase's own tools apply them. The database keeps a list of the ones
# it has, in supabase_migrations.schema_migrations.
psql() { docker exec -i -e PGOPTIONS=-cclient_min_messages=warning supabase-db psql -X -q -U postgres -d postgres -v ON_ERROR_STOP=1 "$@"; }
psql -c "create schema if not exists supabase_migrations;
  create table if not exists supabase_migrations.schema_migrations
    (version text primary key, statements text[], name text)"
applied=$(psql -tA -c "select version from supabase_migrations.schema_migrations")
for f in supabase/migrations/*.sql; do
  [ -e "$f" ] || break
  name=$(basename "$f" .sql) && version=${name%%_*}
  ! grep -qx "$version" <<<"$applied" || continue
  echo "==> Migrating the database: $name"
  { cat "$f"; printf '\n;insert into supabase_migrations.schema_migrations (version, name) values (%s, %s);\n' \
      "'$version'" "'${name#*_}'"; } | psql --single-transaction ||
    fail "migration $name didn't apply"
done

echo "==> Starting the new version"
ln -sfn "$release" "$HOME/app.new" && mv -T "$HOME/app.new" "$HOME/app"
sudo systemctl restart app
for _ in $(seq 30); do
  code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' http://localhost:3000/ || true)
  case $code in 000|5??) sleep 1 ;; *) break ;; esac
done
case $code in
  000|5??) fail "it answers HTTP $code (journalctl -u app -n 50)" ;;
esac
trap - ERR
rm -f "$HOME/.deploy-failed"
ls -1d "$HOME"/releases/*/ | head -n -3 | xargs -r rm -rf  # keep 3
echo "==> Deployed ${rev:0:7}: HTTP $code"
EOF
chmod +x ~/bin/app-deploy

Deploy every minute#

A timer runs it every minute, logging only deploys:

sudo tee /etc/systemd/system/app-deploy.service >/dev/null <<'EOF'
[Unit]
Description=Deploy the app's newest commit from GitHub
After=network-online.target docker.service

[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/bin/app-deploy
# Log the deploys, not the once-a-minute "Starting" and "Finished" lines.
SyslogLevel=notice
LogLevelMax=notice
EOF
sudo tee /etc/systemd/system/app-deploy.timer >/dev/null <<'EOF'
[Unit]
Description=Check GitHub for a new commit to deploy, every minute

[Timer]
OnCalendar=minutely

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload

Restore and back up#

app-restore loads a Lovable export or a backup. It starts Supabase over, loads the export into a scratch database, restore_source, and copies across what the app needs, leaving Supabase's own setup alone: accounts and their identities, your public schema, your triggers and policies on Supabase's tables, applied migrations, and buckets and files, through the Storage API:

curl -fsSL https://exe-staging.dev/deploy/migrate-lovable/app-restore -o ~/bin/app-restore
chmod +x ~/bin/app-restore
head -3 ~/bin/app-restore

~/bin/app-backup saves the database and files to ~/backups nightly:

cat > ~/bin/app-backup <<'EOF'
#!/usr/bin/env bash
# Backs up the database and the stored files. app-backup.timer runs this
# every night; it keeps a week of databases and last night's files:
#   ~/backups/YYYY-MM-DD.backup   the whole database
#   ~/backups/files/              every bucket's files
# To put one back: ~/bin/app-restore ~/backups/YYYY-MM-DD.backup ~/backups/files
set -euo pipefail
mkdir -p ~/backups/files
docker exec supabase-db pg_dump -U supabase_admin -d postgres -Fc -Z zstd > ~/backups/.new.backup
mv ~/backups/.new.backup ~/backups/"$(date +%F)".backup
files=~/supabase/volumes/storage/stub/stub  # none until the first upload
if sudo test -d "$files"; then
  sudo rsync -rlt --delete --chown=exedev:exedev "$files/" ~/backups/files/
fi
ls -1 ~/backups/*.backup | head -n -7 | xargs -r rm
echo "Backed up to ~/backups: $(du -sh ~/backups | cut -f1)"
EOF
chmod +x ~/bin/app-backup
sudo tee /etc/systemd/system/app-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up the app's database and files to ~/backups

[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/bin/app-backup
EOF
sudo tee /etc/systemd/system/app-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the app's database and files, nightly

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer

Check what needs Lovable#

~/bin/app-check lists what in the live release still depends on Lovable:

cat > ~/bin/app-check <<'EOF'
#!/usr/bin/env bash
# Lists what in the live release still depends on Lovable, with the
# section of https://exe.dev/deploy/migrate-lovable that deals with it.
cd "$HOME/app/src"
check() {
  echo "$1:"
  grep -rlE "$2" . --exclude-dir=integrations | sed 's|^\./|  src/|' | grep . || echo "  nothing"
}
check "Sign-in with Google, Apple, or Microsoft (Sign-in)" 'lovable\.auth\.signInWithOAuth'
check "Lovable AI, connectors, or email, with LOVABLE_API_KEY (AI features and connectors)" 'LOVABLE_API_KEY'
check "Routes for scheduled jobs (Scheduled jobs)" 'authenticateCronRequest|LOVABLE_CRON_SECRET'
echo "Settings the server code reads that ~/app.env doesn't set (Settings and secrets):"
grep -rhoE "process\.env(\.|\[['\"])[A-Z0-9_]+" . | grep -oE '[A-Z0-9_]+$' | sort -u |
  grep -vxE 'NODE_ENV|PORT|HOST|LOVABLE_API_KEY|LOVABLE_CRON_SECRET(_PREVIOUS)?' |
  grep -vxFf <(grep -oE '^[A-Z0-9_]+' "$HOME/app.env") | sed 's/^/  /' | grep . || echo "  none"
EOF
chmod +x ~/bin/app-check

Deploy and check#

Deploy, then turn on the timer, take a first backup, and check:

~/bin/app-deploy

The deploy ends with ==> Deployed and a commit.

sudo systemctl enable --now app-deploy.timer
~/bin/app-backup
systemctl is-active app caddy
systemctl list-timers 'app-*' --no-pager
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/
key=$(grep '^VITE_SUPABASE_PUBLISHABLE_KEY=' ~/app.env | cut -d= -f2-)
curl -s http://localhost:8000/auth/v1/settings -H "apikey: $key" | jq .external.email

Expect Backed up to ~/backups, active twice, both timers, 200 (or a redirect to a login page), and true. Then:

~/bin/app-check

Each group names the section that deals with it. Setup is done; continue with Bring your data over.

Google sign-in and email, by hand

Google sign-in#

In Google Cloud, create an OAuth client of type Web application, with JavaScript origin https://my-lovable.exe.xyz and redirect URI https://my-lovable.exe.xyz/auth/v1/callback. Then, on the VM, with its ID and secret:

cd ~/supabase
cat > docker-compose.google.yml <<'EOF'
# Sign in with Google, with your own OAuth client from Google Cloud.
services:
  auth:
    environment:
      GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true"
      GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
      GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET}
      GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/callback
EOF
cat >> .env <<'EOF'
GOOGLE_CLIENT_ID=1234567890-abcdefg.apps.googleusercontent.com
GOOGLE_SECRET=GOCSPX-your-secret
EOF
sh run.sh config add docker-compose.google.yml
sh run.sh recreate auth

"Continue with Google" now works, unchanged. Apple and Microsoft are the same, with GOTRUE_EXTERNAL_APPLE_* or GOTRUE_EXTERNAL_AZURE_*.

Email#

Set your provider's SMTP settings in ~/supabase/.env, with a sender on a domain you've verified with it. For Resend:

SMTP_ADMIN_EMAIL=hello@example.com
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
SMTP_USER=resend
SMTP_PASS=re_your_api_key
SMTP_SENDER_NAME=My App

Then cd ~/supabase && sh run.sh start. To skip confirming addresses instead, set ENABLE_EMAIL_AUTOCONFIRM=true.

Move to a new VM

Set up the new VM by hand (above; ssh exe.dev integrations attach lovable-repo vm:<new-vm> attaches the repository), then copy the backups across, on your computer:

scp -r my-lovable.exe.xyz:backups ./my-lovable-backups
scp -r ./my-lovable-backups <new-vm>.exe.xyz:backups

On the new VM, with the backup's date:

~/bin/app-restore ~/backups/2026-10-20.backup ~/backups/files

Add your own lines from the old ~/app.env to the new one, redo Google sign-in and email, and move your domain as in Make the move.

Update Supabase, Node.js, Bun, and Caddy

The app's packages arrive as commits from Lovable. For Supabase, back up, then merge in the new release, keeping your settings:

~/bin/app-backup
cd ~/supabase
sh update.sh
sh run.sh pull && sh run.sh start

update.sh asks about breaking changes; --dry-run previews. For the tools:

sudo apt-get update && sudo apt-get install -y nodejs caddy postgresql-client-18
sudo npm install -g bun@latest
node --version; bun --version
~/bin/app-deploy --force

For a new Node.js major, rerun NodeSource's setup with its number (setup_26.x) first.

Something not working?

The logs, on the VM:

journalctl -u app-deploy -n 100 --no-pager
journalctl -u app -n 100 --no-pager
docker logs supabase-auth --tail 50
  • A change in Lovable doesn't show up. Check it's on GitHub, on the branch the VM deploys (git -C ~/app.git symbolic-ref --short HEAD), then look for FAILED in journalctl -u app-deploy. Failed commits aren't retried; ~/bin/app-deploy --force retries.
  • FAILED: migration ... didn't apply. Usually real data breaking a constraint; the error is above. Nothing changed, and Shelley can fix the data or the migration.
  • not found or a 403 every minute. The integration was removed or detached; ssh exe.dev integrations list shows it.
  • The build fails, but Lovable's preview works. Ask Lovable to fix the error above FAILED; it usually breaks Lovable's publish too.
  • Row counts don't match after a restore. app-restore's output says which table failed and why; restore_source still holds the export.
  • "Error sending confirmation email" or "provider is not enabled". Email or that sign-in method isn't set up yet; see Sign-in.
  • Signing in lands on localhost:3000. Fix SITE_URL and ADDITIONAL_REDIRECT_URLS in ~/supabase/.env, then cd ~/supabase && sh run.sh start.
  • LOVABLE_API_KEY is not configured. See AI features and connectors.
  • A page fails when loaded directly, but not when you click to it. Its loader uses the browser's Supabase client on the server, which can't reach the public address. Ask Lovable to use a server function.
  • Disk filling up. du -sh ~/releases ~/.bun ~/backups ~/supabase/volumes; bun pm cache rm empties Bun's cache, and Shelley can drop restore_source after a restore.

Frequently asked questions#

What moves, and what stays with Lovable?

Your code is already yours, in GitHub. Lovable's export carries the database: every table and every account, with its password hash and linked Google, Apple, or Microsoft identity. Stored files you download beside it. Secrets, sign-in settings, and Lovable AI and connectors stay behind; this page replaces each.

Will my users have to reset their passwords?

No. The same email and password work on the VM; people just sign in once more. Google users land in their old accounts once you've set up Google sign-in.

Can I keep building in Lovable?

Yes. Each change reaches GitHub, and the VM deploys it within a minute, migrations included. Lovable's preview keeps using Lovable Cloud, as a test copy. See Keep building.

My app was made before May 2026. Does this work?

Not as it is: older Lovable apps are React + Vite single-page apps, and this page needs TanStack Start. Have Lovable upgrade the app first.

Why run Supabase on the VM?

Lovable Cloud is Supabase, so the app's code doesn't change and the export restores as it is, onto a VM you control. A managed Supabase project works too: point ~/app.env at it.

How big a VM does this need?

The default is enough for a typical app: Supabase uses about 1.5 GB of memory and 5 GB of disk. A restore keeps a second copy of the database, so leave room for twice its size plus your files (df -h /).

Updated Oct 22, 2026 · Markdown · manifest.json · Something wrong? Tell us