Move a Lovable app and its data to exe.dev
Your Lovable app, its database, and its users, on a server of your own.
Shelley handles the setup. About 30 minutes.
Prefer a terminal?
curl -fsSL https://exe.dev/deploy/migrate-lovable/prompt.txt | ssh exe.dev new --prompt=/dev/stdinLovable 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
~/supabasewith 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.gitcloned through the GitHub integration, and~/bin/app-deploybuilding the newest commit as a Node.js server, applying new files insupabase/migrations, switching over, and keeping the previous release if anything fails;app-deploy.timerruns 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-backupwithapp-backup.timerenabled, 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
https://my-lovable.exe.xyz/
Your app, until you make it public
https://my-lovable.exe.xyz:8001/
Supabase Studio, behind a password as well
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.dev/deploy/migrate-lovable/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhat Shelley is told
REPO=<fill-this-in>
Set up Apps from Lovable on this VM by following the exe.dev recipe at https://exe.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#
- Deploy sets up the VM: Supabase, and your app with its tables but no data. Lovable keeps serving meanwhile.
- Bring your data over from a Lovable export and check the app. Rehearse as often as you like.
- Sign-in: passwords work at once; Shelley can set up Google sign-in and email.
- Make the move: a final export, your domain on the VM, and
lovable.appunpublished.
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-deploywhen 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.
-
Stop changes on Lovable: Project settings → Unpublish project, and disconnect your domain under Project settings → Domains.
-
Export and restore again, as in Bring your data over. Lovable allows one export a day, so rehearse at least a day before.
-
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 -
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 --forceWith Google sign-in, also add
https://app.example.com/auth/v1/callbackto 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.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 forFAILEDinjournalctl -u app-deploy. Failed commits aren't retried;~/bin/app-deploy --forceretries. 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 foundor a 403 every minute. The integration was removed or detached;ssh exe.dev integrations listshows 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_sourcestill 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. FixSITE_URLandADDITIONAL_REDIRECT_URLSin~/supabase/.env, thencd ~/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 rmempties Bun's cache, and Shelley can droprestore_sourceafter 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