# Fix Railway “Healthcheck failed!” on deploy

> Why a Railway deploy ends in “1/1 replicas never became healthy! Healthcheck failed!”, how to find your cause in the logs, and how to fix each one.

Part 15 of [Platform playbooks](https://polylane.com/series/platform-playbooks/)
By Boris Tane, Founder of Polylane · Published October 18, 2026 · 11 min read
Canonical: https://polylane.com/learn/troubleshooting/railway-healthcheck-failed/

Railway calls your healthcheck path on the PORT it injected and waits up to 300 seconds for a 2xx. If no 2xx arrives, the deploy fails and the previous deployment keeps serving. The usual causes are a server that never listens on 0.0.0.0:$PORT (often because the start command never reaches it), an app that rejects the healthcheck.railway.app hostname, a path that returns a redirect or error, or a startup slower than the timeout.

Your deploy builds fine, then the build log switches to a healthcheck section and fails:

```text
==================== Starting Healthcheck ====================
Path: /health
Retry window: 5m0s
Attempt #1 failed with service unavailable. Continuing to retry for 4m49s
Attempt #2 failed with service unavailable. Continuing to retry for 4m38s
...
1/1 replicas never became healthy!
Healthcheck failed!
```

Railway sent HTTP requests to your healthcheck path for five minutes and never got a 2xx back. Almost always, one of four things happened: no server was listening on `0.0.0.0` and the port in `PORT`, the start command never launched the server, the app refused requests from the hostname `healthcheck.railway.app`, or the endpoint answered with something other than a 2xx. The logs tell you which one, and each has a short fix.

## What “Healthcheck failed!” means on a Railway deploy

When you set a healthcheck path on a service, Railway's [healthcheck docs](https://docs.railway.com/deployments/healthchecks) say it queries that endpoint until it gets any 2xx status code. Only then does the new deployment become active and the old one inactive. The default timeout is 300 seconds. If no 2xx arrives in that window, Railway marks the deploy as failed.

The new deployment fails, and the old one carries on serving. The [deployments reference](https://docs.railway.com/deployments/reference) describes the states: a deployment sits in `Deploying` while Railway waits for the healthcheck, and goes to `Failed` when it never passes. Your users still reach the previous version, so the failed healthcheck has caught a broken deploy before it reached traffic. Your job is to find out why the new container never answered.

Three details about the check matter for debugging:

- **It uses the `PORT` variable.** Railway injects `PORT` and uses its value for the healthcheck. If your app listens somewhere else, the check finds nothing there.
- **It comes from `healthcheck.railway.app`.** Apps that filter by hostname have to allow it, or they answer with an error.
- **It only runs during the deploy.** Railway does not call the endpoint again once the deployment is live.

```mermaid alt="Railway healthcheck flow: dial PORT, check hostname and status, then go Active or fail after the 300 second window"
graph TB
    A[New deploy starts container] --> B{Server listening on 0.0.0.0:PORT?}
    B -->|no| F[Attempt fails: service unavailable]
    B -->|yes| C{App accepts healthcheck host?}
    C -->|no| G[Attempt fails: status 400 or similar]
    C -->|yes| D{Path returns a 2xx?}
    D -->|no| G
    D -->|yes| E[New deploy goes Active]
    F --> H{300 seconds used up?}
    G --> H
    H -->|no| B
    H -->|yes| I[Healthcheck failed, old deploy serves]
    style I fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
    style E fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

## Reading the attempt lines to narrow the cause

The text after each `Attempt #n failed` is your first clue.

- **`failed with service unavailable`** means Railway got no usable answer on `PORT`. Either nothing is listening there, or the app is up but rejecting the request in a way that surfaces as unavailable. Railway's docs list both: not listening on `PORT`, and refusing the `healthcheck.railway.app` hostname.
- **`failed with status 400`** means a server answered, so the port is right. The app rejected the request. On Railway that is usually the hostname check.
- **Any other status**, such as a redirect, a not found or a 500, means the server is up and the path itself is wrong, redirects, or errors. Railway only accepts a 2xx, so a redirect counts as a failure.

Then read the deploy logs for the failed deployment. They hold your app's stdout and stderr, which is where the answer usually is:

```bash
railway logs --latest --lines 200
railway logs --deployment --lines 100
```

`--latest` shows the newest deployment even when it failed. Look for three things in order:

1. **Did the server print a listen line at all?** For example `Listening on 0.0.0.0:8080` or `Uvicorn running on http://0.0.0.0:8080`. No listen line points at cause 2 below.
2. **Which address and port does the listen line show?** `localhost`, `127.0.0.1` or a fixed port that doesn't match `PORT` points at cause 1.
3. **Is there an access log line for the healthcheck request?** A request that arrives and gets an error status points at causes 3 or 4.

## Cause 1: the server listens on the wrong host or port

**How to tell:** the deploy logs show the server started, but on a loopback address or a port that isn't `PORT`.

```text
Server listening on http://localhost:3000
Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
```

The healthcheck reaches your container from outside, so a server bound to `localhost` or `127.0.0.1` can't be reached. A hardcoded port fails the same way when it differs from the `PORT` value Railway injected.

A quieter version of this bites people who set `PORT` by hand. In one [Railway Central Station thread](https://station.railway.com/questions/deploy-problem-healthcheck-always-fail-8c4c3767), a Django service had a `PORT` variable of 3000 while gunicorn bound to 8000, so the healthcheck dialled a port with nothing on it. Whatever `PORT` holds is where Railway looks.

**Fix: read `PORT` and bind to `0.0.0.0`.**

1. Node with Express:
   ```javascript
   const port = process.env.PORT || 3000;
   app.listen(port, "0.0.0.0", () => {
     console.log(`Listening on 0.0.0.0:${port}`);
   });
   ```
2. Python with uvicorn, in the start command:
   ```bash
   uvicorn main:app --host 0.0.0.0 --port $PORT
   ```
3. Python with gunicorn. Railway's docs say gunicorn already listens on `0.0.0.0` and `PORT` by default. If you pass `--bind`, keep it on `$PORT`:
   ```bash
   gunicorn config.wsgi --bind 0.0.0.0:$PORT
   ```
4. If you set a `PORT` service variable yourself, either delete it so Railway assigns one, or make the app listen on exactly that number.
5. If you use target ports on your domains and the app deliberately listens on a port other than the injected one, set `PORT` to the port the app uses. Railway's docs say leaving it out when using target ports can make the healthcheck return `service unavailable`:
   ```bash
   railway variable set PORT=8080
   ```

## Cause 2: the start command never launches the server

**How to tell:** the deploy logs show your setup steps, such as migrations, and then nothing. No listen line, no error. That is exactly what the Central Station thread above showed: migration output, then silence, and eleven `service unavailable` attempts.

The cause there was how Railway runs a start command on a Dockerfile service. Railway's [start command docs](https://docs.railway.com/deployments/start-command) say that for a Dockerfile or image, the start command overrides the image's `ENTRYPOINT` in exec form, and exec form does not expand environment variables. Docker's [Dockerfile reference](https://docs.docker.com/reference/dockerfile/) explains why: exec form doesn't invoke a command shell, so normal shell processing doesn't happen, and chaining with `&&` is shell syntax too. So a start command like this:

```text
python manage.py migrate --no-input && gunicorn config.wsgi --bind 0.0.0.0:$PORT
```

doesn't run as you intended, and `$PORT` stays a literal string. In the thread, the migration output appeared and gunicorn never started. Services built with Railpack run the start command in a shell, so the same line can work there and fail once you add a Dockerfile.

**Fix: give the command a shell, or split it.**

1. Wrap the command in a shell, as Railway's docs show. Use `exec` so the server becomes the main process:
   ```bash
   /bin/sh -c "exec gunicorn config.wsgi --bind 0.0.0.0:$PORT"
   ```
2. Move one-off work such as migrations into a pre-deploy command, and keep the start command to the server alone:
   ```json
   {
     "$schema": "https://railway.com/railway.schema.json",
     "deploy": {
       "preDeployCommand": ["python manage.py migrate --no-input"],
       "startCommand": "/bin/sh -c \"exec gunicorn config.wsgi --bind 0.0.0.0:$PORT\""
     }
   }
   ```
3. Or delete the start command override and let the Dockerfile's `CMD` run, since Railway defaults to the image's `ENTRYPOINT` and `CMD`. The thread's author fixed it this way:
   ```dockerfile
   CMD ["sh", "-c", "python manage.py migrate --no-input && exec gunicorn config.wsgi --bind 0.0.0.0:$PORT"]
   ```

A crash before the listen line looks similar but ends with a stack trace: a missing environment variable, or a database URL that points at `localhost`. Fix the error the log names, then redeploy.

## Cause 3: the app rejects the healthcheck.railway.app hostname

**How to tell:** the attempts fail with `status 400`, or with `service unavailable` while the deploy logs show a correct listen line. Frameworks with host allowlists produce this.

Railway sends healthchecks from `healthcheck.railway.app`, and its docs say an app that doesn't allow that hostname can fail with `failed with service unavailable` or `failed with status 400`.

**Fix: add the hostname to the allowlist.**

Django:

```python
ALLOWED_HOSTS = [
    "your-domain.com",
    ".up.railway.app",
    "healthcheck.railway.app",
]
```

If your allowlist comes from an environment variable, check the value the running service actually has. A placeholder such as `ALLOWED_HOSTS=localhost` set during the build for `collectstatic` is easy to confuse with the runtime value, so confirm the service variable in Railway includes the healthcheck hostname.

## Cause 4: the health path answers, but without a 2xx

**How to tell:** the access log shows the healthcheck request arriving and getting a redirect, a not found or a 500.

Railway accepts any 2xx and nothing else. The usual offenders:

- **A typo or missing route.** The healthcheck path in settings points at `/health` and the app serves `/healthz`.
- **HTTPS redirects.** Middleware that redirects plain HTTP to HTTPS answers the check with a redirect. Exempt the health route from the redirect.
- **Auth on every route.** A global login check returns 401 or a redirect to the login page. Leave the health route public.
- **A dependency check that fails.** A health route that queries the database returns 500 when the connection string is wrong. That is the check doing its job: Railway's [credential rotation guide](https://docs.railway.com/guides/rotate-credentials-zero-downtime) recommends a healthcheck that exercises the credential, so a deploy with a bad `DATABASE_URL` fails and the old deployment keeps serving. Keep the check and correct the variable.

A minimal health route returns 200 once the app can serve requests:

```python
# FastAPI
@app.get("/health")
def health():
    return {"status": "ok"}
```

## Cause 5: startup takes longer than the timeout

**How to tell:** the deploy logs show progress, and the listen line, if it appears at all, comes after the healthcheck window has closed.

The default window is 300 seconds. Long migrations, large model downloads or cache warming in the start command can push past it.

**Fix: start faster, or give it longer.**

1. Move migrations and other one-off work to `preDeployCommand`, as shown above, so the serving container listens straight away.
2. If the app really needs longer, raise the timeout in the service settings, with a `RAILWAY_HEALTHCHECK_TIMEOUT_SEC` service variable, or in config:
   ```json
   {
     "$schema": "https://railway.com/railway.schema.json",
     "deploy": {
       "healthcheckPath": "/health",
       "healthcheckTimeout": 600
     }
   }
   ```

Raising the timeout only helps when the log shows the server eventually listening. If it never listens, more time changes nothing.

## Reproduce Railway's healthcheck before you push

You can run the same check locally and save a five-minute wait per failed deploy. Build the image, run it with a `PORT` that differs from your default, and call the health path with Railway's hostname:

```bash
docker build -t myapp .
docker run --rm -e PORT=8123 -p 8123:8123 myapp

# in a second terminal
curl -sS -o /dev/null -w "%{http_code}\n" \
  -H "Host: healthcheck.railway.app" \
  http://localhost:8123/health
```

Expected output:

```text
200
```

A connection refused means causes 1 or 2. A 400 means cause 3. A redirect, a not found or a 500 means cause 4. Picking an unusual port for the test catches hardcoded ports that a common default would hide.

## Mistakes that keep a Railway healthcheck failing

- **Raising the timeout first.** It only fixes cause 5. Read the deploy logs for a listen line before touching it.
- **Setting `PORT` to match the code.** It works until someone changes one side. Read `PORT` in code and leave the variable to Railway unless you use target ports.
- **Chaining commands in a Dockerfile service's start command.** `&&` and `$PORT` need a shell. Wrap with `/bin/sh -c` or use `preDeployCommand`.
- **Pointing the healthcheck at a heavy page.** A home page that renders templates and calls APIs is slow and fails for reasons unrelated to readiness. Use a small dedicated route.
- **Removing the healthcheck path to make deploys pass.** Without one, Railway marks a deployment `Active` as soon as the container starts, and a broken build takes traffic.
- **Expecting a short blip to disappear on volume services.** Railway prevents two deployments from mounting the same volume, so a redeploy of a service with a volume has a small amount of downtime even with a healthcheck.

## Watching the service after the healthcheck passes

Railway's docs are plain that the healthcheck is not used for continuous monitoring: it runs at the start of each deploy and stops. A service that passes and then falls over an hour later will not trip it. Railway points to the Uptime Kuma template for an outside check on a schedule. You can also send `Failed` and `Crashed` deployment events to Slack or Discord through a project webhook, so a failed healthcheck reaches a person without anyone watching the dashboard. If a deploy passes its healthcheck but users then get a 502, the guide to Railway's “Application failed to respond” error covers that side.

## Catching failed Railway deploys with Polylane

Polylane detects production issues and fixes them by opening pull requests. It connects to Railway, so when a deploy fails its healthcheck, it works out why, and the fix arrives ready for your team's normal review.

Running on Railway? See [how Polylane monitors Railway in production](https://polylane.com/for/railway/).

## Common questions

**How long does Railway wait for a healthcheck to pass?**

300 seconds by default, shown in the build log as a retry window of 5m0s. You can raise it in the service settings, with the RAILWAY_HEALTHCHECK_TIMEOUT_SEC service variable, or with healthcheckTimeout in railway.json. Only raise it if the deploy logs show the server does start listening, just late.

**What does “failed with service unavailable” mean in a Railway healthcheck?**

Railway got no usable answer on the port in PORT. Most often nothing is listening there, because the server bound to localhost, used a different port, or never started. Railway's docs say an app that rejects the healthcheck.railway.app hostname can also produce this message.

**Which port does the Railway healthcheck use?**

It uses the value of the PORT variable Railway injects into the deployment. If your app listens on another port, for example because you use target ports on your domains, set a PORT service variable to the port the app actually listens on.

**Why does my start command work with Railpack but fail with a Dockerfile?**

With Railpack, Railway runs the start command in a shell, so $PORT and && work. With a Dockerfile or image, the start command overrides ENTRYPOINT in exec form, and Docker's reference says exec form doesn't invoke a shell, so shell processing such as variable expansion doesn't happen. Wrap it as /bin/sh -c "exec your-server --port $PORT" or move setup steps to preDeployCommand.

**Does a failed healthcheck take my site down?**

No. The new deployment is marked Failed and never becomes active, so the previous deployment keeps serving traffic.

**Does Railway keep calling my healthcheck after the deploy goes live?**

No. Railway only calls the healthcheck while a new deployment is going live and does not use it for continuous monitoring. Use an outside uptime check, such as the Uptime Kuma template, and a project webhook for Crashed deployment events.

**Should my healthcheck query the database?**

It can, and Railway's credential rotation guide recommends a healthcheck that exercises credentials, so a deploy with a bad DATABASE_URL fails safely. Keep the query cheap so the check answers quickly.

**Why does my Django app fail the Railway healthcheck with status 400?**

Django's settings docs say that with DEBUG off, an ALLOWED_HOSTS setting that isn't set properly results in requests being returned as Bad Request (400), and Railway sends healthchecks from healthcheck.railway.app. Add that hostname to ALLOWED_HOSTS in the settings your production deploy actually loads, then redeploy.

## Sources

- [Healthchecks (Railway Docs)](https://docs.railway.com/deployments/healthchecks)
- [Deployments reference (Railway Docs)](https://docs.railway.com/deployments/reference)
- [Set a Start Command (Railway Docs)](https://docs.railway.com/deployments/start-command)
- [Rotate API Keys and Database Credentials Without Downtime (Railway Docs)](https://docs.railway.com/guides/rotate-credentials-zero-downtime)
- [Deploy problem: Healthcheck always fails (Railway Central Station)](https://station.railway.com/questions/deploy-problem-healthcheck-always-fail-8c4c3767)
- [Dockerfile reference (Docker Docs)](https://docs.docker.com/reference/dockerfile/)
- [Settings (Django documentation)](https://docs.djangoproject.com/en/stable/ref/settings/)
- [Fix Railway “Application failed to respond” (Polylane)](https://polylane.com/learn/troubleshooting/railway-application-failed-to-respond/)
- [Polylane documentation](https://docs.polylane.com/llms-full.txt)
- [Polylane full content](https://polylane.com/llms-full.txt)

## About the author

Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.

## More in this series

[Platform playbooks](https://polylane.com/series/platform-playbooks/)

1. [How to Monitor a Django App on Render](https://polylane.com/learn/monitoring-coverage/how-to-monitor-a-django-app-on-render/)
2. [How to Monitor a FastAPI App on Railway: Logs, Traces and Alerts](https://polylane.com/learn/monitoring-coverage/how-to-monitor-a-fastapi-app-on-railway/)
3. [How to Monitor a Supabase App in Production](https://polylane.com/learn/monitoring-coverage/how-to-monitor-a-supabase-app-in-production/)
4. [How to Debug Cloudflare Workers Errors: Logs, Traces and Error Codes](https://polylane.com/learn/troubleshooting/how-to-debug-cloudflare-workers-errors/)
5. [How to debug Vercel function timeouts](https://polylane.com/learn/troubleshooting/how-to-debug-vercel-function-timeouts/)
6. [Vercel 504 Gateway Timeout on Serverless Functions: Causes and Fixes](https://polylane.com/learn/troubleshooting/vercel-504-gateway-timeout-on-serverless-functions/)
7. [Cloudflare Workers error 1101: causes and how to fix it](https://polylane.com/learn/troubleshooting/cloudflare-workers-error-1101/)
8. [Cloudflare Hyperdrive connection errors: causes and fixes](https://polylane.com/learn/troubleshooting/cloudflare-hyperdrive-connection-errors/)
9. [How to Monitor a Convex App in Production](https://polylane.com/learn/monitoring-coverage/how-to-monitor-a-convex-app-in-production/)
10. [How to roll back a Cloudflare Workers deployment](https://polylane.com/learn/deployment-safety/how-to-roll-back-a-cloudflare-workers-deployment/)
11. [How to Monitor Vercel Cron Jobs: Heartbeats, Logs and Alerts](https://polylane.com/learn/monitoring-coverage/how-to-monitor-vercel-cron-jobs/)
12. [How to reduce Vercel Function cold starts](https://polylane.com/learn/troubleshooting/how-to-reduce-vercel-function-cold-starts/)
13. [How to Monitor a Next.js App on Render](https://polylane.com/learn/monitoring-coverage/how-to-monitor-a-next-js-app-on-render/)
14. [Modal container out of memory: find the cause and fix it](https://polylane.com/learn/troubleshooting/modal-container-out-of-memory/)
15. [Fix Railway “Healthcheck failed!” on deploy](https://polylane.com/learn/troubleshooting/railway-healthcheck-failed/)

Previous: [Modal container out of memory: find the cause and fix it](https://polylane.com/learn/troubleshooting/modal-container-out-of-memory/)

## Related

- [Fix Railway “Application failed to respond” (502)](https://polylane.com/learn/troubleshooting/railway-application-failed-to-respond/): Why Railway returns “Application failed to respond” with a 502, how to tell a wrong host, port or target port from a crash or overload, and how to fix each.
- [Fix Render “Port scan timeout reached, no open ports detected”](https://polylane.com/learn/troubleshooting/render-port-scan-timeout-reached-no-open-ports-detected/): Why a Render deploy fails with “Port scan timeout reached, no open ports detected”, how to spot the cause in the logs, and how to bind to 0.0.0.0 on PORT.
- [Railway activity feed filters and cron jobs in the CLI](https://polylane.com/learn/continuous-integration/railway-activity-feed-filters-cron-jobs-cli/): Railway's 16 October 2026 update: filter the activity feed by service, list and trigger cron runs with railway cron, and migrate railway.json to IaC.
- [Kubernetes CrashLoopBackOff: causes and fixes](https://polylane.com/learn/troubleshooting/kubernetes-crashloopbackoff/): What CrashLoopBackOff means in Kubernetes, how to read the exit code and events behind it, and the fix for each common cause, from bad config to failing probes.

Get started with one command: `curl -fsSL https://polylane.com/setup | bash` installs the CLI, connects your coding agents, and creates the account.
