Introduction

Most people building apps today are not engineers.

They open Claude Code or Cursor. They describe what they want. They get a working app on their laptop. Then they hit a wall.

The app runs on localhost:3000. Nobody else can see it. Every guide on how to fix that assumes you already know what a container is.

The obvious first thought is to build a platform that detects the framework and generates the install, build and start commands. I started there. It was the wrong place to start.

Railway already does this, with an open source tool called Railpack. It handles Node, Python, Go, Ruby, PHP, Java, Rust and more. It reads lockfiles to pick the package manager. It reads version files to pick the runtime. It is good, and it is free.

So the question changed. If building the container is solved, what is left?

What is left is everything around it. Railpack answers “how do I build this”. It does not answer “why is this broken”. It will happily build an app with a hardcoded port, and that app will die on the health check. It does not know the app needs a database key. It does not check whether the page actually loads in a browser.

That gap is the product. The user's code carries assumptions about their laptop, and nobody tells them which ones will break.

Here is the pipeline that closes it. You upload a zip. You get a live URL — and, if what you uploaded was several pieces rather than one app, every one of them online and wired to each other.

The Deployment Flow

1. Intake and unpack

Unzip the file locally. Basic rules first.

  • Enforce a size cap.
  • Reject any file path that tries to escape the folder.
  • Strip the junk: node_modules, .git, __MACOSX, virtual environments, build output.

One nice side effect of zip uploads: people usually include their .env file. Git users do not, because it is ignored. So we often get the real values for free. We hold them and ask the user to confirm later.

2. Service discovery

A project may be one app. It may be a frontend and a backend in one folder. We have to work out which, and in what order to deploy them.

This step has its own pipeline, below.

2a. File search for all manifests

Walk the folder and collect every manifest: package.json, requirements.txt, go.mod, Gemfile, Cargo.toml, and so on. A bare index.html with no manifest counts too.

Each one is a candidate. Not a service yet.

2b. Determine the monorepo shape

Two shapes exist, and they need opposite settings.

  • Shared workspace. Signals: a root package.json with a workspaces field, a pnpm-workspace.yaml, a turbo.json, or one lockfile at the root with none in the child folders. Root directory stays at the top. Build and start commands are filtered per service.
  • Isolated. Signals: each child folder has its own lockfile, and there is no workspace config anywhere. Each service gets its own root directory.

Get this backwards and the build fails with an error that looks like a dependency problem.

2c. Prune non-services

A shared library is not deployable. We score each candidate.

  • Positive signals (this is a service): a Dockerfile or Procfile present, a start or serve script, a port binding found in the source, a server framework in the dependencies, a bundler config next to an index.html.
  • Negative signals (this is a library): main, exports or types fields with no start script, no server dependency, no bundler config, sitting in a packages/ or shared/ folder.

If the signals conflict, we do not guess. We ask.

2d. Role classification

Every surviving service gets one of three labels.

  • Static frontend. Building it produces a folder of files. Nothing runs after. Signals: vite, react-scripts, astro, a build output directory.
  • Server. A program that stays running and answers requests. Signals: express, fastapi, flask, rails in the dependencies.
  • Worker. A program that stays running but has no URL. Background jobs and queues. Signals: a start script, no port anywhere, a queue library like bullmq or celery.

Workers matter more than they look. Give a worker a public URL and an HTTP check, and you will mark a perfectly healthy service as broken.

When we cannot tell, we guess server. Calling a server “static” kills the deploy, because nothing runs. Calling a static site “server” works fine and costs slightly more. Guess in the direction where being wrong is cheap.

2e. Edge detection

Which service needs another service's address?

On the laptop, the frontend calls localhost:5000 and it works, because the backend is on the same machine. Once deployed they are on different machines, and localhost points at nothing.

We look for those references, most trustworthy first.

  • docker-compose.yml, if it exists. The user already drew the diagram.
  • Vite proxy config.
  • .env files, for keys like VITE_API_URL.
  • Hardcoded URLs in the source, like fetch("http://localhost:5000/api").
  • CORS settings in the backend, which give the reverse link. The backend needs the frontend's address too. This creates a loop between the two services, which is normal and fine.

The port number is the key. If the frontend mentions port 5000, and one backend listens on 5000, that is the link.

Two notes on this whole discovery pipeline.

  • Every stage above is the same machine with different inputs: gather signals, score them, produce an answer with a confidence level. So we build that engine once and reuse it. Adding support for a new framework becomes writing a signal, not editing the discovery logic.
  • Edge detection is less about ordering than it sounds. We already know every URL at provision time, in step 6. The real reason ordering matters is so we do not run a health check on a frontend before its backend is up.

2.1. Reject early

If the project uses something we cannot support, or there is nothing deployable in it at all, we stop here and say why.

This happens before any resources exist. A fast, honest rejection is better than a ten minute build that fails.

3. Static analysis for common problems

Now we look for the things that break between the laptop and the server.

  • The port is hardcoded. The app says listen(3000). On a laptop that is fine. On a server, the platform picks the port and passes it in as an environment variable. The app listens on 3000. The platform checks 8080. Nothing answers. The deploy is marked dead.
  • The app binds to localhost. Inside a container, localhost means “this container only”. Nobody outside can reach it. It needs 0.0.0.0.
  • The frontend calls the backend at localhost. Same problem as edge detection above, but here it is a hardcoded string in the source with no variable to set.
  • Environment variables are missing. The app reads DATABASE_URL. On the laptop, the .env file supplies it. In the container it is undefined. The app builds fine, then crashes on start or on first use.
  • There is no lockfile. package.json does not pin versions, it gives ranges. ^4.18.2 means anything below 5.0.0. The user installed in March and got 4.18.2. We install in August and get 4.21.0. Different code. The lockfile is the record of what the user actually has. Without it, we install from ranges and the versions drift.

Steps 2 and 3 run as one stage. Both only read files and write to a scratch copy. Nothing outside has been touched yet, so if the process dies here we just run it again.

Every finding lands in one of three buckets.

  • We know the answer. Hardcoded ports, localhost binds. Fix it.
  • Only the user knows the answer. Database keys, API keys. Ask.
  • Nobody can fix it here. A private package registry we have no access to. Stop and say why.

4. Ask the user, once

If there are questions, ask them all on one screen. Not one at a time. Not during the deploy.

  • Unknown services from step 2. “We found packages/shared. Is that something you want online, or code your other parts use?”
  • Environment variable values from step 3, pre-filled from the uploaded .env where we found them.
  • A one-glance summary of the plan. “We found a backend and a frontend. We will deploy the backend first, then connect the frontend to it.”

If there are no questions, this screen is skipped entirely. That is the goal.

One rule here. The test for whether to ask is: could a perfect analyser answer this on its own? If yes, do not ask.

Asking a non-technical person “may we change your port binding” is not consent. They cannot evaluate it. They will click yes and feel uneasy. So we fix it and tell them what we changed, in plain words, on a screen they were already going to see.

5. Apply the fixes

After the gate, apply the deterministic fixes from step 3.

  • listen(3000) becomes listen(process.env.PORT || 3000).
  • localhost binds become 0.0.0.0.
  • Hardcoded API URLs become a variable we can set.

Only changes that cannot break a working app qualify as fixes. Anything riskier is a question or a warning instead.

Every change is recorded in plain language and shown to the user at the end. We are editing their code. They should never find that out by accident.

6. Create and configure the services

Now we touch Railway.

  • Create one service per node in the graph from step 2.
  • Set the root directory, or the filtered commands, depending on the monorepo shape.
  • Generate the public URLs now, before anything is built.
  • Provision the shared pieces — a database, a cache — if anything needs them.
  • Work out the complete set of environment variables for every service, and set them all before the first build starts.

The URL point matters more than it sounds. A frontend built with Vite or Next bakes the backend's URL into its files at build time. If we wait until the backend is running to hand over its address, the frontend has already been built with nothing. And the backend needs the frontend's address too, for CORS — so each is waiting on the other, and no deploy order can untangle it.

Because Railway hands out a domain as soon as the service exists, every URL is known before any build starts. Allocate all the addresses first and the cycle simply disappears.

The database is one per project, not one per service. A monorepo is one product; separate databases per piece is a microservice pattern, and it is not what someone who wrote a website and an API meant. We generate its password once, when the database is created, and never write it down — every service that needs it gets a reference the platform resolves inside the container, not the password itself.

It also gets a disk. That sounds like a detail and is not: a database without persistent storage loses everything on the next deploy, and someone who does not know what a container is will never work out why their content vanished. Better to give them no database at all than one that quietly forgets.

This is also where the “ask only what a person can answer” rule earns its keep. A frontend's API address, a backend's allowed origins, a database URL, whether a framework should run in debug mode — all of these are computed here. If we asked the user for any of them, we would be asking a question we were about to answer ourselves two steps later.

7. Build and start

Push the code. Railpack does the work: detect, install, build, package, run.

Two exceptions, both found the hard way. Railpack detects a workspace and installs it correctly, but it cannot know which app in that workspace this service is meant to run — so a workspace member gets an explicit, filtered start command. And Railpack’s Python provider only looks at root-level files, so the app/main.py layout most real Python web apps use gets no start command at all; we work out the app object ourselves and set one.

Everything else, we leave alone. We send the build and start commands as explicit nulls, so a stale override from an earlier attempt cannot survive into the next one.

Deploy in order. Backends before the frontends that call them.

8. Verify that it actually works

This is the part I care most about.

A normal health check asks: did the server return 200? That certifies almost nothing.

The most common failure for these users is a white screen. The site builds. The site serves. It returns 200. And the browser shows nothing, because the JavaScript crashed, or because every API call is still pointing at localhost.

Here is the thing that makes this hard. A perfectly healthy Vite deploy serves exactly this:

<body>
  <div id="root"></div>
</body>

An empty mount point and a script tag. That is textually identical to a broken one. Reading the HTML cannot tell them apart, so the signal has to come from somewhere else.

It comes from the files the page references.

  • Does the page render, or is it an error page wearing a 200? A Vite host-allowlist block, a Cannot GET /, a default nginx page, or the platform’s own “nothing is deployed here” response — each has a signature, and each is a different diagnosis.
  • Do the bundles actually load? We follow every script and stylesheet the page asks for. A 404 on the main bundle is the single most common cause of a white screen, and unlike a runtime crash it is completely visible from outside.
  • Is anything still calling localhost? A frontend’s API address is baked into its bundle at build time — so if it is still pointing at the author’s laptop, the string is sitting right there in the JavaScript. We read it without executing a line of it.

That catches most of the white-screen class. It does not catch all of it: a JavaScript exception that blanks a page whose bundles all loaded fine is invisible from outside the browser. Running a real browser, collecting console errors, and showing the user a screenshot of their own site is the next thing we are building — and until it ships, we are careful that the finish message never claims more than was actually checked.

8.1. Slow is not the same as broken

A container takes time to start. DNS takes time to spread. If the check runs too early and fails, and we treat that as a real failure, we will start editing perfectly good code for no reason.

So there are two layers.

  • Wait until reachable. Poll with backoff up to a ceiling. Connection refused, DNS not resolving, a 502 from the edge: all of these mean “not ready”, keep waiting.
  • Then verify. Only once the service answers do we run the real check. Failures from here are genuine, and only these go to the agent.

A crashed process is terminal, because the log says so. A silent port is transient until the ceiling runs out.

Most of that waiting turns out to happen upstream. Giving the platform a health check path of its own means a deploy is not reported as successful until the service answers — so “success” already means “it responded”, not merely “the container started”, which was the whole reason this step existed.

8.2. The check depends on the role

Step 2 already labelled every service, so we use that.

  • Static frontend: the full set of checks above.
  • Server: an HTTP probe, expecting any non-5xx response. A 404 at / is a routing choice, not a broken deploy. If it serves HTML we follow its assets too — but we do not hold it to the “does this render” bar, because a server replying ok is fine and a frontend rendering ok is not.
  • Worker: no URL to check. Confirm the process is alive and not crash looping.

Without this, every worker we deploy fails verification while being perfectly healthy.

9. The recovery loop

Steps 7 and 8 are wrapped in a loop, per service.

When a build fails, or verification fails, an agent takes over. It can read and edit files, and it can read the build logs. It applies a fix and redeploys. Four attempts, then it stops.

The user never sees a log. They see progress on each piece separately, then their live address, or one plain sentence explaining what went wrong.

Three limits we set deliberately.

  • Cross-service failures are out of scope for now. If the frontend breaks because the backend is subtly wrong, the agent only edits the service it is currently working on. Anything else becomes an honest failure message.
  • A budget for the whole run, not just per service. Four attempts each, with a cap on the total. Otherwise a six-piece bundle costs six times what one app does on a single upload.
  • On total failure, tear everything down. Failed runs leave services that keep costing money. Logs and findings are saved first, then the resources go.

That last one has an exception worth stating, because it is the case a bundle actually hits. If your backend came up and your frontend did not, we do not tear the backend down to tidy up. It is genuinely running and genuinely reachable, and taking it away because something else failed would destroy work that succeeded. The run is reported as partly live, naming which piece is up and which is not — because “deployment failed” would be untrue, and “deployment succeeded” would hide a site that does not work.

Closing

The hard part of deployment is not building the container. That is solved, and solved well.

The hard part is that “works on my machine” is a promise about a machine nobody else has. The code assumes a fixed port, a local database, a localhost address, a set of package versions installed months ago. None of those assumptions survive the move, and the person who wrote the code has no way to know which one broke.

So the job is not to be a better builder. The job is to read the project, know what will break, fix what can be fixed, ask only what a person can actually answer, and then check that the thing you built is genuinely serving before telling anyone it is.