---
title: "Move a Lovable app and its data to exe.dev"
description: "Move a Lovable app off Lovable Cloud to an exe.dev VM: its TanStack Start server, and Supabase with your database, users, and files."
url: https://exe.dev/deploy/migrate-lovable
category: "Migration"
updated: 2026-10-22
---

# Move a Lovable app and its data to exe.dev

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

- **Software:** Apps from Lovable (https://lovable.dev)
- **Source:** https://github.com/supabase/supabase/tree/master/docker
- **License:** Proprietary (Lovable); Apache-2.0 (Supabase); your app's code is yours
- **Time:** 30 minutes
- **Example VM name:** `my-lovable` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/migrate-lovable/manifest.json
- **File:** https://exe.dev/deploy/migrate-lovable/app-restore

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

Running on the VM:

- **Caddy**: The front door on port 8000; Supabase's APIs and the app at one address, port 8000
- **Your Lovable app**: TanStack Start's Node.js server, built from GitHub, as app.service, port 3000, data in `~/app → the current release in ~/releases`
- **Supabase**: Database, sign-in, storage, and realtime in Docker, in place of Lovable Cloud; Studio on port 8001, port 8001, data in `~/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, data in `~/app.git, ~/backups`

How it is reached:

- https://my-lovable.exe.xyz/ (private): Your app, until you make it public
- https://my-lovable.exe.xyz:8001/ (private): Supabase Studio, behind a password as well

## Deploy with Shelley

Open https://exe.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Apps%20from%20Lovable%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fmigrate-lovable.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20REPO%3A%20the%20GitHub%20repository%20Lovable%20syncs%20your%20project%20to%2C%20as%20owner%2Fname%20%28Lovable%20shows%20it%20under%20Project%20settings%20%E2%86%92%20Git%20%E2%86%92%20GitHub%29%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-lovable.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-lovable.%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-%20Supabase%20running%20in%20Docker%20in%20%60~%2Fsupabase%60%20with%20keys%20of%20its%20own%2C%20restarting%20after%20a%20reboot%2C%20and%20Caddy%20on%20port%208000%20serving%20its%20APIs%20and%20the%20app%20at%20https%3A%2F%2Fmy-lovable.exe.xyz%2F.%0A-%20%60~%2Fapp.git%60%20cloned%20through%20the%20GitHub%20integration%2C%20and%20%60~%2Fbin%2Fapp-deploy%60%20building%20the%20newest%20commit%20as%20a%20Node.js%20server%2C%20applying%20new%20files%20in%20%60supabase%2Fmigrations%60%2C%20switching%20over%2C%20and%20keeping%20the%20previous%20release%20if%20anything%20fails%3B%20%60app-deploy.timer%60%20runs%20it%20every%20minute.%0A-%20The%20app%20answering%20on%20port%208000%20as%20%60app.service%60%2C%20on%20a%20database%20with%20the%20app%27s%20tables%20and%20no%20data%20yet.%0A-%20%60~%2Fbin%2Fapp-restore%60%2C%20as%20published%20with%20the%20recipe%2C%20ready%20to%20load%20a%20Lovable%20Cloud%20export%3B%20%60~%2Fbin%2Fapp-backup%60%20with%20%60app-backup.timer%60%20enabled%2C%20and%20a%20first%20backup%20in%20%60~%2Fbackups%60.%0A-%20The%20output%20of%20%60~%2Fbin%2Fapp-check%60%2C%20saying%20what%20in%20this%20app%20still%20depends%20on%20Lovable.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details.%20Follow%20%22Set%20up%20by%0Ahand%22%20from%20%22Install%20Node.js%2C%20Bun%2C%20Caddy%2C%20and%20PostgreSQL%27s%20tools%22%20to%20the%0Aend%20of%20it%2C%20as%20exedev%3B%20the%20walkthrough%20above%20it%20is%20for%20the%20owner%0Aafterwards.%20Do%20not%20run%20%22Move%20to%20a%20new%20VM%22%20or%20%22Update%22%2C%20do%20not%20reboot%2C%0Aand%20leave%20public%20sharing%20off.%20The%20scripts%20and%20units%20were%20tested%20as%0Awritten%3A%20use%20them%20unchanged%2C%20check%20that%20yours%20match%20the%20recipe%2C%20and%0Aleave%20no%20test%20files%20behind.%0AREPO%20is%20a%20GitHub%20repository%3B%20turn%20a%20URL%20into%20owner%2Fname.%20Clone%20it%0Athrough%20the%20GitHub%20integration.%20If%20that%20fails%20with%20%22not%20found%22%20or%20a%20403%2C%0Aand%20https%3A%2F%2Fgithub.com%2FOWNER%2FNAME%20is%20public%2C%20clone%20that%20instead%3B%0Aotherwise%20give%20the%20owner%20the%20integration%20command%20from%20%22Get%20the%20code%22%2C%0Awith%20this%20VM%27s%20name%2C%20and%20wait%20for%20them.%20Install%20everything%20else%20meanwhile.%0ANever%20commit%20or%20push%20to%20the%20repository%3A%20Lovable%20writes%20to%20it.%0AIf%20the%20first%20deploy%20stops%20because%20this%20isn%27t%20a%20TanStack%20Start%20app%2C%20say%0Aso%20and%20point%20the%20owner%20to%20the%20FAQ%20on%20older%20apps%3B%20leave%20the%20rest%20set%20up.%0ADon%27t%20wait%20for%20the%20Lovable%20export%3A%20the%20owner%20downloads%20it.%20Don%27t%20run%0Aapp-restore%20until%20they%20ask%20you%20to.%0AKeep%20keys%20and%20passwords%20out%20of%20your%20replies%3B%20say%20which%20file%20holds%20them.%0AIn%20your%20report%2C%20say%20what%20~%2Fbin%2Fapp-check%20found%2C%20in%20plain%20words%2C%20with%20the%0Asection%20of%20this%20recipe%20that%20deals%20with%20each%20item.%20Then%20give%20the%20owner%0Athe%20next%20step%3A%20export%20from%20Lovable%20as%20%22Bring%20your%20data%20over%22%20says%2C%20the%0Ascp%20command%20with%20this%20VM%27s%20name%2C%20and%20to%20ask%20you%20to%20restore%20it.%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/migrate-lovable/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

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](#faq)). First connect the project to GitHub in Lovable
(**Project settings → Git → GitHub → Connect**), and link your GitHub
account on exe.dev's [Integrations page](https://exe.dev/integrations),
so the VM can read the repository without a token.

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

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

```bash
~/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](#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](#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:

```text
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](#google-sign-in-and-email). For an internal tool, you
can also keep the VM private and [share](/docs/sharing) 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](https://docs.lovable.dev/features/drafts), 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](https://exe.dev/integrations)
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](/docs/integrations-llm):
OpenAI-compatible like Lovable's gateway, with no key, billed to your
exe.dev account. Ask Lovable (or Shelley):

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

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

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

<details id="job-timer" class="dp-details">
<summary>A job as a systemd timer</summary>

With your route and schedule, on the VM:

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

</details>

## 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](#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](/docs/cnames)):

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

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

<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-lovable` is taken):

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

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

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

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

Then, on the VM:

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

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

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

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

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

```bash
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](https://exe.dev/deploy/migrate-lovable/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:

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

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

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

```bash
~/bin/app-deploy
```

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

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

```bash
~/bin/app-check
```

Each group names the section that deals with it. Setup is done; continue
with [Bring your data over](#bring-your-data-over).

</details>

<details id="google-sign-in-and-email" class="dp-details">
<summary>Google sign-in and email, by hand</summary>

### Google sign-in

In [Google Cloud](https://console.cloud.google.com/auth/clients), 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:

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

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

</details>

<details id="restore" class="dp-details">
<summary>Move to a new VM</summary>

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

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

```bash
~/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](#make-the-move).

</details>

<details id="update" class="dp-details">
<summary>Update Supabase, Node.js, Bun, and Caddy</summary>

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

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

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

</details>

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

The logs, on the VM:

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

## 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](#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](#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](https://docs.lovable.dev/features/upgrade-to-tanstack-start) 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 /`).


## Related

- [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.
- [Migrate a Heroku app to exe.dev](https://exe.dev/deploy/migrate-heroku.md): Your Procfile, config vars, and Postgres on a VM of your own.
- [Migrate a DigitalOcean Droplet to exe.dev](https://exe.dev/deploy/migrate-digitalocean-droplet.md): Same server, new home. Rehearsed while the droplet keeps serving.
- [Migrate a Durable Object from Cloudflare](https://exe.dev/deploy/migrate-durable-objects.md): Same Worker, same objects, new home. Every key and row checked.
