Originally published on the OpsCrew blog.
TL;DR
- Lovable projects created on or after 13 May 2026 are TanStack Start apps that need a running Node.js server. Older ones are React and Vite apps that build to static files.
- Check package.json. If it lists
@tanstack/react-start, you have the server version. - Connect GitHub in Lovable's Git settings, on any plan. Lovable creates a private repository and keeps it in sync both ways.
- Build a Vite app into dist/ and serve it with an index.html fallback. Build a TanStack Start app with
NITRO_PRESET=node-serverand run node .output/server/index.mjs. - Set
VITE_SUPABASE_URLandVITE_SUPABASE_PUBLISHABLE_KEYbefore the build, because they are compiled into the JavaScript. A TanStack Start app's server also readsSUPABASE_URLandSUPABASE_PUBLISHABLE_KEYwhere it runs. - The backend can stay on Lovable Cloud. Add the new domain to its sign-in redirect URLs before you switch.
To deploy a Lovable app to your own server, sync it to GitHub, check which of Lovable's two stacks it uses, and build it accordingly: a React and Vite project becomes static files that any web server can serve, and a TanStack Start project becomes a Node.js server that has to keep running. Put either behind a reverse proxy with HTTPS, set the Supabase values before the build, and add the new domain to the sign-in redirect URLs. This guide does each step for both stacks. It is part of the guide to deploying an AI-built app to your own server, which covers what is common to every builder.
The Lovable facts here come from Lovable's documentation, checked on 11 November 2026. The Dockerfiles were built and run that day on Node.js 22, against a fresh Vite app from npm create vite@latest (Vite 8, React template) and a fresh TanStack Start app from the TanStack CLI with its Nitro deployment option.
First: which Lovable app do you have?
Lovable changed its default stack in 2026, and the two need different hosting:
- TanStack Start, the default for new projects since 13 May 2026 (and for new Enterprise projects since 22 June 2026). It renders pages into HTML on the server, and has server functions and API routes. It needs a host that runs Node.js; a static file host alone will not work.
- React and Vite, every older project unless you upgraded it. It builds to static files in
dist/and routes in the browser, so any web server can host it, provided it answers unknown paths withindex.html.
To check, open package.json in Lovable's Code tab or in your repository. If @tanstack/react-start is in the dependencies, it is a TanStack Start app. Upgrading an older project is optional: Lovable keeps Vite projects working.
Step 1: Sync the code to GitHub
Git sync works on every Lovable plan with github.com. Only a workspace owner or admin can add the connection:
- Open Workspace settings, then Git, then GitHub, and choose Add connection.
- Pick the account or organisation, choose which repositories the app may see, and click Install & Authorize.
- In the project's Git settings, click Connect. Lovable creates a new private repository and starts syncing.
The sync goes both ways: changes in Lovable are pushed to the repository, and commits pushed to the synced branch (by default main) come back into Lovable. You can keep building in Lovable after you move the hosting. Lovable always creates a new repository; it cannot connect to one that already exists. On a paid plan you can instead download a one-time .zip snapshot.
Neither route includes your database rows or uploaded files. Those live in the backend, which step 5 deals with.
Clone the repository and run the app locally before you write a Dockerfile. Lovable's docs note that their npm examples assume a package-lock.json: if the repository has only bun.lock, either use Bun or run npm install once and commit the package-lock.json it writes. The Dockerfiles below use npm ci, which needs that file.
Step 2: Collect the build-time values
A Lovable app on the built-in backend reads three values from the .env file in the repository:
VITE_SUPABASE_URLVITE_SUPABASE_PUBLISHABLE_KEYVITE_SUPABASE_PROJECT_ID
Vite exposes any variable that starts with VITE_ to the browser and replaces it in the code at build time. That has two consequences. These values have to be present when the image is built, or the app ships without them and cannot reach its data. And anything in a VITE_ variable is readable by every visitor, which is fine for the Supabase URL and publishable key (they are meant to be public, and Row Level Security is what protects the data) and never fine for a secret.
The Dockerfiles below take the values as build arguments and keep .env out of the image, so what goes into a build is always explicit. Vite gives variables that are already set in the environment priority over .env files, so the build arguments win either way.
A TanStack Start app needs the same values a second time. Lovable's hosting guide says its server code also reads SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY (and SUPABASE_PROJECT_ID), set to the same values as the browser ones, as environment variables where the server runs. The compose.yaml in step 4 passes them to the container.
Step 3a: The Dockerfile for a Vite Lovable app
This builds the app with Node.js 22, then copies only dist/ into a small Caddy image that serves the files on port 8080:
# Build stage: Node 22 builds the static files into dist/ FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . # VITE_ variables are compiled into the JavaScript at build time ARG VITE_SUPABASE_URL ARG VITE_SUPABASE_PUBLISHABLE_KEY ARG VITE_SUPABASE_PROJECT_ID RUN npm run build # Run stage: Caddy serves dist/ and sends unknown paths to index.html FROM caddy:2-alpine COPY spa.Caddyfile /etc/caddy/Caddyfile COPY --from=build /app/dist /srv The spa.Caddyfile next to it is what makes client-side routes work. Without try_files, refreshing /dashboard asks the server for a file called dashboard, which does not exist, and the visitor gets a 404:
:8080 { root * /srv encode try_files {path} /index.html file_server } Add a .dockerignore so local builds, Git history and your .env stay out of the image:
node_modules dist .git .env .env.* In our test, a request for /dashboard/settings returned the app's index.html with status 200, and the Supabase URL passed as a build argument appeared in the built JavaScript while the value in a local .env did not.
Step 3b: The Dockerfile for a TanStack Start Lovable app
A TanStack Start app is built by Nitro, which has a preset per platform. For a container you want the Node.js server preset, and the result is a self-contained server in .output/. First check that package.json lists nitro. If it does not, follow TanStack's hosting guide: install it with npm install nitro and add nitro() from nitro/vite to the plugins in vite.config.ts.
FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . # Browser values are embedded at build time ARG VITE_SUPABASE_URL ARG VITE_SUPABASE_PUBLISHABLE_KEY ARG VITE_SUPABASE_PROJECT_ID ENV NITRO_PRESET=node-server RUN npm run build FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production PORT=3000 HOST=0.0.0.0 COPY --from=build --chown=node:node /app/.output ./.output USER node EXPOSE 3000 CMD ["node", ".output/server/index.mjs"] The run stage copies only .output/, because Nitro bundles the server and the dependencies it needs into it. The server listens on port 3000, set by PORT and HOST. Do not add a try_files fallback in front of it: the server handles routing itself. Server-only secrets (an API key a server function uses, for example) go into the container's environment at run time, not into build arguments.
Use the same .dockerignore as above, with .output added. The Supabase values the server reads at run time come from compose.yaml, in the next step.
Step 4: Run it behind Caddy on your server
Both stacks sit behind the same reverse proxy, which takes ports 80 and 443, gets a certificate for your domain and forwards requests to the app. The Caddyfile:
app.example.com { reverse_proxy app:8080 } Use app:8080 for the Vite image above and app:3000 for TanStack Start. Then a compose.yaml that builds the app with its build arguments and runs Caddy beside it:
services: app: build: context: . args: VITE_SUPABASE_URL: ${VITE_SUPABASE_URL} VITE_SUPABASE_PUBLISHABLE_KEY: ${VITE_SUPABASE_PUBLISHABLE_KEY} VITE_SUPABASE_PROJECT_ID: ${VITE_SUPABASE_PROJECT_ID} environment: # Read at run time by TanStack Start's server code; the Vite image ignores them SUPABASE_URL: ${VITE_SUPABASE_URL} SUPABASE_PUBLISHABLE_KEY: ${VITE_SUPABASE_PUBLISHABLE_KEY} SUPABASE_PROJECT_ID: ${VITE_SUPABASE_PROJECT_ID} restart: unless-stopped caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config volumes: caddy_data: caddy_config: Replace app.example.com with your domain. Docker Compose reads a .env file next to compose.yaml for the ${...} substitutions, which fill both the build arguments and the server's run-time variables, and Lovable keeps the three values in .env at the project root, so a clone of the repository usually has them already; if yours does not, create the file on the server. The .dockerignore keeps the file itself out of the image: the values reach the build only as the arguments listed. Point your domain's A record at the server and start it:
docker compose up -d --build Caddy gets the certificate as soon as the domain resolves to the server and ports 80 and 443 are open. We ran this stack with localhost as the site address, where Caddy uses its own local certificate authority; HTTP was redirected to HTTPS and both stacks answered through the proxy. A public certificate for a real domain needs a real server, which the test did not use.
If you do not have a server yet, deploying a web app to a VPS covers creating one, SSH keys, the firewall and Docker, and Hetzner vs DigitalOcean helps you choose a provider.
Step 5: Keep the backend, or move it
Lovable Cloud, the built-in backend, is built on Supabase: database, sign-in, storage, realtime, edge functions and secrets. You have three options for it.
Keep Lovable Cloud (the simplest move). Lovable documents hosting the frontend elsewhere while keeping the built-in backend, and you can go on building in Lovable, with its AI features and app connectors still working. You set the new host's environment variables and server secrets, and add the new domain to the sign-in redirect URLs. In Supabase terms, that is the Site URL (the default redirect address that confirmation and password-reset emails use) and the list of allowed Redirect URLs; any Google or GitHub sign-in also needs the new address in that provider's OAuth settings.
Move to your own Supabase project. This is the reference path in Lovable's docs. In short: create the project, apply the migrations in supabase/migrations/ in order, restore the data export (it includes user accounts and password hashes, so users keep their passwords but must sign in again), reconfigure the sign-in providers and redirect URLs, copy the storage files, set the secrets, deploy the edge functions, recreate scheduled jobs, test on a separate deployment, and only then switch the values in .env and supabase/config.toml. Lovable's full export is a PostgreSQL custom-format archive with zstd compression, restored with pg_restore, and you can request one every 24 hours. Calls to Lovable's AI features and app connectors keep running through Lovable until you replace them with your own provider accounts, so plan that replacement as part of the move.
Self-host Supabase. For data residency or full control, Lovable points to Supabase's own Docker self-hosting guide. A plain PostgreSQL database is not enough: the app needs Supabase's auth, storage, realtime and edge services too.
Move the frontend first. Once the app runs on your server against the backend it already has, moving the backend is a separate project with its own test run.
Step 6: Test, then switch the domain
Lovable's own checklist is the right one. On a temporary address:
- Refresh a nested route. A 404 means a missing fallback (Vite) or a missing server (TanStack Start).
- Sign in and out. A redirect error means the address is not in the allow list.
- Read, write and upload something.
- For TanStack Start, load a server-rendered page and call a server function.
- Search the code for the old
lovable.appaddress, hardcoded in links, emails or callbacks.
Then point your custom domain at the server. The lovable.app address keeps serving your last Lovable publish until you unpublish it, which gives you a fallback while DNS settles. Exporting code and moving the domain do not cancel your Lovable plan or Cloud usage; change those yourself when you are done.
Two other guides in this series cover the same ground for similar apps: a Bolt.new app, whose database is Bolt Database or your own Supabase project, and a v0 app, which is always Next.js.
Doing this with OpsCrew
OpsCrew recognises a repository exported from Lovable, and its free opscrew scan checks it before it goes live for leaked keys and exposed Supabase data, such as a table without Row Level Security. It then builds the image in GitHub Actions, with its own Dockerfile for a Vite app or with yours when the repository has one (such as the TanStack Start Dockerfile above), treats VITE_ values as public build-time values, and deploys to a new server on Hetzner Cloud or DigitalOcean or to one you already have, with HTTPS on your domain. A static Vite app can also go on Cloudflare's hosting with no server. Each change waits for your approval of its plan. Use cases shows a scan of a test app shaped like a Lovable export.
For everything that is the same across builders, go back to the guide to deploying an AI-built app to your own server.
Sources
- Deploying and hosting outside Lovable, Lovable
- Deployment, hosting, and ownership options with Lovable, Lovable
- Upgrade a project to TanStack Start, Lovable
- Sync your Lovable project with GitHub, Lovable
- Built-in backend, Lovable
- TanStack Start hosting, TanStack
- Env variables and modes, Vite
- Automatic HTTPS, Caddy
- Redirect URLs, Supabase


