Builds

How Lucity turns your source code into a container image.

A build takes a commit from your repository and produces a container image that Lucity can run. You do not describe how: the builder reads your project, works out what language and framework it is, installs the right toolchain, installs your dependencies, runs your build script, and assembles an image around the result.

This happens on every deployment of a service built from a repository. Services created from an existing image skip all of it.

Detection

Detection runs against the files already in your project. A package.json makes it a Node project, a go.mod a Go project, a requirements.txt or pyproject.toml a Python one. The same goes for Bun, Deno, PHP, Ruby, Rust, Java, .NET, Elixir, Gleam and C++, and a repository with none of those still builds if it has a Procfile, a shell script, or a directory of static files.

Frameworks get their own handling on top of the language. Next.js, Astro, Vite, Angular, React Router and Expo are recognized on the Node side, Django on the Python side, Laravel on the PHP side, each with the build and start commands that framework expects. The guides cover the ones people ship most.

Toolchain versions come from wherever your project already declares them: the go directive in go.mod, engines in package.json, a .python-version file. If nothing says, you get a current stable version, and you can pin one explicitly with a variable.

The start command is derived the same way, from your start script, your entrypoint, or the binary that was just compiled. You can override it on the service afterwards.

Build steps

Every step shows up in the build log, which you find in the service's Deployments tab, under the Build step of the deployment. It streams while the build runs.

  1. Clone. The repository is fetched at the commit being deployed.
  2. Detect. The builder inspects the repository and produces a plan containing the toolchain to install, the commands to run, and how to assemble the image.
  3. Install. The toolchain goes in first, then your dependencies. Your lockfile and manifest are copied in on their own before the install command runs, without the rest of the project.
  4. Copy the source. The rest of your project goes in after the dependencies are already there.
  5. Build. Your build command runs. This is the step where next build, vite build, go build or mix compile happens.
  6. Assemble and push. The toolchain, your dependencies and your built application are combined into a runtime image, which is pushed to the platform registry and tagged with the short commit hash.

Installing dependencies before the rest of your source is copied in is what makes rebuilds fast. Change application code without touching your lockfile and the dependency install is cached.

Build-time variables

Every variable on the service is available while it builds, not just while it runs. That is what makes NEXT_PUBLIC_*, VITE_* and friends work, and it is also how you configure the builder itself.

It cuts both ways. A variable that is read at build time is baked into the image, so changing it does not take effect until the next build, even though saving it rolls the service out. Anything read at runtime picks up the new value on that rollout.

Keep secrets out of the parts of your build that write to the image. An API key inlined into a JavaScript bundle ships to every visitor, and one baked into an image layer stays readable to anyone who can pull that image. Read secrets at runtime instead, where a rotation reaches the service on the next rollout.

Build configuration

The builder reads RAILPACK_-prefixed variables as instructions. Set them like any other variable on the service:

VariableWhat it does
RAILPACK_INSTALL_CMDReplaces the detected dependency install
RAILPACK_BUILD_CMDReplaces the detected build command
RAILPACK_START_CMDReplaces the detected start command
RAILPACK_PACKAGESExtra toolchain packages, for example python@3.12 alongside Node
RAILPACK_BUILD_APT_PACKAGESSystem packages available while building
RAILPACK_DEPLOY_APT_PACKAGESSystem packages present in the final image

Versions get their own variables per language: RAILPACK_NODE_VERSION, RAILPACK_PYTHON_VERSION, RAILPACK_GO_VERSION, RAILPACK_RUBY_VERSION, RAILPACK_RUST_VERSION, RAILPACK_JDK_VERSION, and so on.

Some providers add their own. Go reads RAILPACK_GO_BIN when a repository has several binaries under cmd/, Angular reads RAILPACK_ANGULAR_PROJECT, PHP reads RAILPACK_PHP_ROOT_DIR. Railpack's environment variables reference(opens in a new tab) has the full list.

Custom build plans

For anything the variables cannot express, put a railpack.json at the root of your build directory. It is read as configuration and gives you direct control over the plan: the packages to install, the steps to run, what each step depends on, and what ends up in the final image.

Railpack's configuration file reference(opens in a new tab) documents the format.

Root directory

A service does not have to build from the top of the repository. Each one has a root directory, and pointing each service at its own folder is how a monorepo works here: apps/api for the API, apps/web for the web app, one service each.

Everything resolves relative to that directory: detection, the install and build commands, and any railpack.json. The clone still covers the whole repository, so shared packages outside the root directory are on disk, but whether your tooling can reach them is down to your package manager rather than to Lucity.

The root directory is set when the service is created and cannot be changed afterwards. The dashboard does not offer the field yet, so today it is set through the API:

curl https://lucity.cloud/graphql \
  -H "Authorization: Bearer $(lucity token)" \
  -H "Content-Type: application/json" \
  -d '{"query":"mutation { addService(environment: \"acme/shop/production\", input: { name: \"api\", repository: \"acme/shop\", contextPath: \"apps/api\" }) { id } }"}'

Asking the Claude plugin to add the service does the same thing without the GraphQL, since its add_service tool takes the root directory as context_path.

A push to the tracked branch deploys every service that tracks it, whether or not anything under its directory changed. There is no path filtering, so in a busy repository turn off auto-deploy and deploy from a workflow instead, where the paths filter in GitHub Actions decides which services a commit should touch.

Caching

Every service has a build cache. Each successful build fills it, and the next one starts from it, which is why a first build takes minutes and the ones after it usually take far less.

What gets reused follows the order of the steps:

  • Changing your lockfile reinstalls dependencies, and everything after that runs again.
  • Changing application code reuses the dependency install and reruns the build command.
  • Changing a variable invalidates the build from the point where variables are read onwards.
  • The toolchain install is reused until you change the version.

The very first build for a service has nothing to import, and the log says so with a line that looks alarming and is not:

#1 importing cache manifest from ...:buildcache
#1 ERROR: failed to configure registry cache importer: ...:buildcache: not found

That is the cache being absent on a first build. The build carries on and populates it.

Troubleshooting

If a build fails, start with the build log. You find it in the service's Deployments tab, under the Build step of the deployment that failed. Nothing is deployed when a build fails, so the version that was already running keeps running and there is no rush.

Most failures are ordinary and the log says so plainly: a dependency that will not install, a type error, a script that does not exist. Two are worth knowing about because the log is less forthcoming. A build that stops mid-command without printing an error has usually run out of memory, and deploying again is worth trying before you go hunting for a cause. A build still running after 30 minutes is cancelled, and builds are never retried automatically.

If the log does not make it obvious, hand it to the Claude plugin. It can read the failing build's log alongside your local checkout, work out which change broke it, and usually fix it and deploy again.

Deploying a commit that is already building joins the build in progress rather than starting a second one.

A Dockerfile in your repository is ignored. The builder works from your project files rather than from build instructions you write. When you need exact control over the image, build it yourself, publish it, and add it as a service from an image instead.

Under the hood

Detection and the build plan come from Railpack(opens in a new tab). The Railway team open-sourced the one part of their platform that felt like magic, and we took them up on it. The plan is compiled into a BuildKit(opens in a new tab) build graph and solved by a shared BuildKit daemon, which is what does the layer caching and the parallelism you can see in the log.

What comes out is an ordinary OCI image in the platform's registry. There is nothing Lucity-specific inside it, so the same image runs under any container runtime, which is part of what makes ejecting work.

Next steps

  • Deployments for what happens once the image exists
  • Variables for the build-time and runtime split
  • Services for ports, health checks and resources
  • Guides for what the builder does with your particular framework