Choosing Nixpacks vs. Dockerfile on Railway: When to Keep It Simple and When to Take Control
Railway's zero-config Nixpacks are great for quick starts, but a Dockerfile gives full control. Learn the trade-offs, variable wiring for multi-service apps, and a copy-paste example.
13 Apr 2026, 18:42 UTC

Problem: “I want my app on Railway, but I’m not sure whether to trust the auto‑detect builder or write a Dockerfile.”
Railway’s default build system, Nixpacks, is a zero‑config open‑source builder that infers the stack from your code. It’s fast, it’s opinionated, and for many projects it works out of the box. But when you need custom tooling, multi‑stage builds, or a precise runtime environment, you may feel the need to hand‑craft a Dockerfile instead.
Deciding between the two is more than a stylistic choice; it affects build time, image size, security, and how you expose environment variables to other Railway services. The following sections break down the practical differences, show how Railway’s variable system lets you wire services together, and give a ready‑to‑copy example that demonstrates both build paths.
Why the Choice Matters
Railway’s railway init creates a railway.toml that tells the platform how to build each service. By default, the platform runs nixpacks build for each service. Nixpacks uses a language‑specific package.json, requirements.txt, Gemfile, etc., to pick a base image and install dependencies. If your repo contains a mix of languages or a non‑standard build step, Nixpacks can pick the wrong plan. A Dockerfile removes that ambiguity and gives you explicit control over the base image, build stages, and runtime.
In addition, the way environment variables are injected differs subtly. Nixpacks will automatically expose PORT and any Railway‑provided variables, but you may want to override defaults or add custom variables that only apply to a specific service. A Dockerfile can use ARG and ENV to set build‑time and run‑time values, and you can still reference Railway variables in your code.
Section 1 – Nixpacks: Zero‑Config, Zero‑Worry
For a typical Node.js or Python web app, Nixpacks can spin up a container in seconds. The steps are:
- Create a new Railway project.
railway init - Push a repo that contains a
package.jsonorrequirements.txt. - Deploy; Railway will automatically run
nixpacks buildand exposePORT.
Build logs look like this (simplified):
✔ Building app (Nixpacks)
Using Node 20
Installing dependencies
Building image
✔ Build complete (3.2 MiB)
Benefits:
- Fast turnaround – no Dockerfile to maintain.
- Automatic port binding to
0.0.0.0:$PORT. - Railway injects service variables such as
RAILWAY_PRIVATE_DOMAINandRAILWAY_SERVICE_URL.
Limitations:
- Inferred stack may mis‑detect mixed‑language repos.
- You cannot control multi‑stage builds or custom base images.
- Some advanced tooling (e.g., custom build scripts, specific Node versions) may not be expressible.
Section 2 – Dockerfile: Full Control, More Responsibility
When you need a custom runtime, a specific base image, or a multi‑stage build, a Dockerfile is the way to go. Railway still runs the Docker build, but you write the Docker instructions yourself. A minimal example for a Node.js app looks like this:
# railway-app/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app .
EXPOSE $PORT
CMD ["node", "dist/index.js"]
Key points:
- Use
EXPOSE $PORTso Railway can inject the port variable. - Railway still injects
RAILWAY_PRIVATE_DOMAIN,DATABASE_URL, etc. You reference them in your code, not in the Dockerfile. - If you need build‑time variables, use
ARGand--build-arg.
Deploying a Dockerfile‑based service is identical to Nixpacks: push the repo and Railway will detect the Dockerfile automatically. The build log will show Docker build steps instead of Nixpacks inference.
Section 3 – Wiring Multi‑Service Apps with Railway Variables
Railway lets you create multiple services in a single project. Common patterns are an app service and a database service (PostgreSQL, MySQL, Redis, etc.). Railway automatically creates a private DNS name for each service, e.g., app.railway.app and postgres.railway.app. The app can reference the database via the DATABASE_URL variable that Railway injects.
Example setup:
- In the Railway dashboard, add a Postgres service.
- In
railway.toml, define the app service and setvariables = { DATABASE_URL = "${RAILWAY_DATABASE_URL}" }. - Your Node.js code reads
process.env.DATABASE_URL.
Railway’s variable system supports project‑level, environment‑level (dev, prod), and service‑level scopes. Use project‑level for secrets that all services share, and service‑level for variables that only apply to that service. Remember to never commit secrets to the repo; keep them in Railway’s variable editor.
Section 4 – Preview Environments and Watch Paths
Railway’s preview environments spin up for each PR, using the same railway.toml. If you have a monorepo, you can configure watch_paths to limit rebuilds to the changed service. For example, in railway.toml:
[services.app]
watch_paths = ["app/**"]
Watch paths are version‑sensitive; verify the syntax in the current dashboard or CLI docs. Misconfigured watch paths can cause unnecessary rebuilds or missing changes in preview environments.
Concrete Worked Example – Node App + Postgres
Below is a minimal, copy‑pasteable setup that uses a Dockerfile for the app and the default Postgres service. The example demonstrates variable injection and private networking.
Directory layout
my‑railway‑project/
├─ app/
│ ├─ Dockerfile
│ ├─ package.json
│ ├─ src/
│ │ └─ index.js
│ └─ .env.example
├─ railway.toml
└─ README.md
app/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app .
EXPOSE $PORT
CMD ["node", "dist/index.js"]
app/package.json
{
"name": "my-app",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"pg": "^8.10.0"
}
}
app/src/index.js
const { Client } = require('pg');
const http = require('http');
const client = new Client({ connectionString: process.env.DATABASE_URL });
client.connect();
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello from Railway!');
});
const port = process.env.PORT || 3000;
server.listen(port, '0.0.0.0', () => {
console.log(`Server listening on port ${port}`);
});
railway.toml
[project]
name = "my-railway-project"
[services.app]
path = "app"
# Dockerfile is detected automatically
variables = { DATABASE_URL = "${RAILWAY_DATABASE_URL}" }
watch_paths = ["app/**"]
Deploy steps:
- Run
railway initin the project root. - Add a Postgres service via the dashboard.
- Push the repo to your Git provider; Railway will build the Dockerfile, inject
DATABASE_URL, and expose the app on its private domain. - Open
https://app.railway.appto see the response.
Verification: In the Railway dashboard, check the Logs tab for the app. Look for a line like Server listening on port 8080 (Railway sets PORT=8080 by default). Confirm the app can query the Postgres instance by adding a route that runs SELECT 1 and inspecting the output.
Trade‑Offs & Limitations
- Nixpacks is great for quick prototypes and single‑language projects but can mis‑detect in multi‑language monorepos. If you see a wrong base image or missing dependencies, switch to Dockerfile.
- Dockerfile gives you full control but requires maintaining build stages, base images, and ensuring the container listens on
0.0.0.0:$PORT. A missing port binding will cause Railway’s health check to fail. - Both approaches rely on Railway’s variable system; if you need to rotate secrets, use the dashboard rather than hard‑coding them.
- Preview environments automatically spin up, but watch paths must be accurate; otherwise you’ll get stale builds. Always run
railway previewlocally before pushing a PR to confirm the preview behaves as expected. - Pricing is usage‑based; an always‑on service that never sleeps can accrue costs quickly. Set
max_instancesor use Railway’s auto‑scale limits to control spend.
Actionable Checklist
- Start with Nixpacks if your repo is a single‑language web service.
- Switch to a Dockerfile if you need custom tooling, multi‑stage builds, or a specific runtime.
- Use Railway’s variable editor to inject
DATABASE_URLand other secrets; avoid committing them. - Define
watch_pathsinrailway.tomlfor monorepos to keep rebuilds fast. - Verify the app binds to
0.0.0.0:$PORTand that health checks pass. - Monitor the billing page and set resource limits for always‑on services.
By following these steps, you’ll choose the build strategy that matches your project’s complexity, keep your secrets safe, and ensure reliable deployments on Railway.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.