Back to writing

5 September 20267 min read

How to put a Base44 app behind your company sign-in

A step by step guide to moving an app you built on Base44 onto a URL that only your colleagues can open, with the login enforced outside the app's own code.

You built something on Base44. It works. It is sitting on a base44.app address, and someone in IT has started asking who else can open it. This is the sequence for moving it onto a URL that only the right people can reach, with the login enforced outside the app's own code.

What OurTools does here, and what it does not

Base44 builds apps. OurTools hosts them. There is no button on our side that reaches into a Base44 project and pulls it across, and there is no automatic conversion of anything. What OurTools takes is source code: a folder, a zip, or a branch in a GitHub repository. So the first question is whether you can get the source out of your project. If you can, everything below applies. If you cannot, there is nothing to upload, and no amount of platform on our side changes that.

The second thing worth saying up front is that whatever your app calls over the network keeps calling it. If the code talks to an API, a Supabase project or a spreadsheet, it still does after the move. OurTools runs your code and decides who reaches it. It does not replace anything the code depends on.

Step 1. Get the code in

Two routes, and they end in the same place.

  • Drag a folder or a zip onto /apps/new. Every decision about the files is made in the browser before a byte leaves the machine, so an unsupported stack or an oversized folder is refused before the upload starts rather than after it.
  • Connect a repository at /apps/new/github. Pick a repository, pick a branch, then look at what OurTools found on it. Each of those three steps is a plain server-rendered link, so the choosing half works with JavaScript off, and the detected framework is on screen before the deploy button is.

If your export arrives as a zip with everything inside one wrapper folder, drop it in as it is. The common root is stripped before anything else happens, and it repeats up to five times, so myapp/dist/index.html becomes index.html. That ordering is deliberate. A dist folder dropped on its own is the whole app and survives, while a dist sitting beside src is build output and is thrown away.

Step 2. What OurTools detects

Detection is one pure function over the file listing and the parsed package.json, in packages/shared/src/framework.ts. The browser, the GitHub import, the command line client and the MCP server all call it, so the verdict the drop zone gives you is the verdict the deploy carries forward. The Go builder, which is the one place the rules can run after the tarball is unpacked, holds a separate port of the same function, kept in step by a shared table of cases. Four stacks, and the list is short on purpose.

  • Static HTML and JavaScript: an index.html at the root and no package.json.
  • Vite: a package.json with vite in dependencies or devDependencies.
  • Next.js: a package.json with next. If next.config has no output set to standalone, OurTools adds it.
  • Single-file Python with Flask or FastAPI: a requirements.txt or pyproject.toml naming one of them, plus exactly one candidate entry .py file at the root. The names that are never an app entry, setup.py, conftest.py, manage.py, wsgi.py, asgi.py and test files, are not counted, so app.py sitting beside setup.py is fine.

A React front end exported from a builder usually lands on the Vite path, or on the static path when what you have is an already-built site with an index.html and no package.json. Either is fine.

The refusals are specific rather than a shrug. A package.json depending on neither next nor vite is refused with a hint naming the fix. More than one candidate entry file at the root is refused with the count and the file names, because version one supports one entry file. Nothing recognisable at the root is refused with a reminder that if the app lives in a subfolder, you should deploy that subfolder.

Step 3. Why the secrets never go in the upload

One list governs what gets packed, in packages/shared/src/ignore.ts, and three places read it: the browser filtering a dropped folder, the command line client packing a tarball, and the API explaining why an archive was too large. A folder the page accepts is a folder the command line client would have packed the same way.

.env and .env.* are dropped every time. The sample files, .env.example, .env.sample and .env.template, are kept, because they carry no values and are how apps document themselves. So are node_modules, venv, __pycache__, .next, .turbo, dist, build, out, coverage and *.log. Lockfiles are the opposite case. package-lock.json, pnpm-lock.yaml, yarn.lock, requirements.txt, poetry.lock, uv.lock and the rest are never ignored, even when your own .gitignore lists them, because a build without a lockfile resolves whatever the registry offers that minute.

There is a rule inside that rule worth knowing. A line reading !.env in a .gitignore does not bring a secrets file back. The same line in a .ourtoolsignore does. The distinction is intent.

A .gitignore was written for git by somebody who had never heard of us, so reading it as permission to upload a database password would be inventing consent.

The failure this prevents is not hypothetical. A repository with a committed .env, deployed as a static app, would serve DATABASE_URL from https://your-app.ourtools.app/.env as plain text to everyone the app was shared with. The repository path applies the same baseline for exactly that reason, and does not read the repository's own .gitignore at all: git already applied that file when it built the archive, so anything still in the tarball is something the repository chose to commit.

If you need to change what is packed, put a .ourtoolsignore next to your app. It uses gitignore syntax and is layered last, so it can overrule the baseline.

# .ourtoolsignore, gitignore syntax, layered after the baseline
design-assets/
*.mov

# The baseline drops build output. This app ships it.
!dist/

The cap is 200 MB for the packed archive, measured after the filter, not before it. You get the breakdown of what is left, largest folder first, so you can see what to remove.

Step 4. Put the values back as environment variables

Whatever was in your .env goes back through the dashboard or the command line, and it goes one way. Nothing prints a value and no endpoint returns one: a read answers with three asterisks, and the encrypted columns are never selected. Values are encrypted with a master key held in Secret Manager and opened in the builder's memory at deploy time. The number of variables is logged. A value never is.

ourtools env set DATABASE_URL="$DATABASE_URL" --secret
ourtools env ls
ourtools deploy .

A few rules that save a round trip. Keys are uppercase letters, digits and underscores, up to 128 characters, and cannot start with a digit. Values cap at 32 KiB. PORT, K_SERVICE, K_REVISION, K_CONFIGURATION and GOOGLE_APPLICATION_CREDENTIALS are reserved because the runtime sets them, and anything starting with OURTOOLS_ belongs to the platform. New values reach the app on its next deploy, not the moment you save them.

Now the caveat that catches people. Environment variables are read after the image is built and injected on the running container. They are not present during the build. A Vite app bakes its VITE_ prefixed variables in at build time, so setting one in OurTools will not put it in your bundle. That is a smaller loss than it sounds, because anything baked into a front-end bundle is readable by anyone who opens the app. Keep real credentials on a server the app calls, not in the page.

If the app needs a database, bringing your own connection string is the default and the answer for most apps. Set DATABASE_URL, or whichever variable your code reads, to a MongoDB Atlas, Supabase, Firebase, PlanetScale or Neon string, or to the database your company already runs, and nothing is provisioned. If you would rather not run one, OurTools can attach Postgres, MySQL or SQLite instead, and DATABASE_URL is then set for you and locked. SQLite is single instance, and the file is copied to storage on shutdown and every few minutes, so writes since the last copy are lost if the instance dies abruptly.

Step 5. Choose who can open it

Nobody but you can open the app until you say so. You keep edit on anything you created, whatever the rules say, so tidying up a share panel cannot lock you out of your own tool. For everyone else, access is four kinds of rule, resolved in a fixed order: an explicit rule for one person beats a workspace rule, which beats an email-domain rule, which beats a share link, and no match is a refusal. Within one rank, edit beats view. The same table is implemented in TypeScript and again in the Go proxy, so the dashboard and the enforcement point cannot drift apart.

  • One person, by email, at view or edit.
  • Everyone in the workspace.
  • Everyone with an email at a domain you name, which is the everyone at acme.com case.
  • A share link, for someone with no account.

A share link is shown once. Only its sha256 is stored, so it cannot be recovered or replayed, and changing the rule set revokes the links that exist. When someone arrives with a token in the query string, the proxy moves it into a cookie scoped to that one app, valid for seven days, and redirects so the token leaves the address bar.

Step 6. Pick where it runs

The region is chosen per app, when the app is created, from the 43 regions listed in packages/shared/src/regions.ts. Cities across North and South America, Europe, the Middle East, Africa, Asia and Australia. It is set once: passing a region on a later deploy prints a warning saying the app stays where it is, rather than ignoring the flag quietly and leaving you believing it moved.

What the person on the other end sees

They click the link and land on your app's own address. What happens next depends on what the proxy finds.

  • Not signed in: a short page reading "Sign in to continue. This app is behind your company login.", then a redirect to the OurTools sign-in with the original URL carried in the query string. Continue with Google, continue with Microsoft, or email and password.
  • Signed in and allowed: the app, with no OurTools interface wrapped around it. Your code receives the verified email, the user id and the permission as X-OurTools- headers. Any X-OurTools- header a caller tried to forge was deleted first. The platform session cookie and the share-link cookie are removed before the request reaches you, and every other cookie is put back, so your app's own sessions still work.
  • Signed in and not allowed: a page saying they do not have access, with a request access button. Asking sends the owner a note with their name and email.
  • Asleep: a page saying the app is waking up, which reloads itself after a few seconds. Apps sleep when nobody is using them, which is what keeps them cheap to run.
  • Switched off by an admin, stopped by the owner, archived, or a failed build: each gets its own page saying which, in plain language, and none of them delete anything.

Every one of those pages is a single file with inline CSS and no external request, because a page that needed a font from somewhere else would be a page that sometimes fails to render on the day the app is already broken. Each carries the same footer: this app is behind your company login.

Deploy, access, share, environment change, rollback, kill, login and database change are all written to an audit log. CSV export of it is on the Business plan.

What is not supported, plainly

  • No importer for Base44 or any other builder, and no automatic conversion. The input is source code.
  • Anything outside the four stacks above. The detector looks for next or vite in the root package.json, so a project depending on neither is refused with the supported list and a hint.
  • Multi-file Python. One candidate entry .py file at the root, with the rest moved into a package folder. setup.py, conftest.py, manage.py, wsgi.py, asgi.py and test files can stay where they are, because they never count as the entry.
  • Environment variables during the build. They arrive on the running container, not in the image.
  • Moving an app to another region after it is created. Deploy it again as a new app instead.
  • Requests over 25 MB through the proxy. An app that accepts large file uploads needs a different route for them.
  • Keeping existing share links while you change the access rules. Changing the rules revokes them, and you mint a new one.

Before any of that, the free scan

It is worth knowing what else is already out there. The scan at ourtools.app/scan takes a company name, a company domain and a work email, plus any brand terms you want to add, and finds apps published on base44.app, lovable.app, bolt.host, replit.app, repl.co, vercel.app, netlify.app and the rest. It uses public information only: certificate transparency logs, public DNS records, and links from your own website. It never signs in, never guesses file names, never follows a redirect into a login page, and stores the address, the page title and the platform fingerprint rather than any page content. You get the summary at a private link straight away, and the full report goes to your mailbox when the address is at the domain you entered.

It also tells you what it missed. A subdomain covered by a wildcard certificate leaves no individual trace in a transparency log: measured on 3 September 2026, lovable.app showed 1877 wildcard entries against 339 named ones. An empty result that reads as "this company has no apps" would be confidently wrong in the direction the reader wants to believe, so the report states its coverage rather than presenting a count as complete.

See what your own team has already put online

The free scan looks for apps published on the tools people build with, using nothing but public information. No account needed.

Run the free scan