Port & Binding
The single most common cause of a "build succeeded but health check failed" deploy is an app that either:
- Listens on a hardcoded port (e.g.
3000) instead of reading$PORT, or - Binds to
127.0.0.1(localhost) instead of0.0.0.0
Both mistakes look the same from the outside: the build finishes, the container starts, but Vessl's health check returns a timeout or connection refused, and the deploy is marked Failed.
How Vessl routes traffic
When your app is deployed, Vessl puts a Traefik reverse proxy in front of it. Traefik forwards incoming HTTP requests to your container on whatever port you declared in the app settings (default: 3000 for Node.js, 8000 for Python/Django, 8080 for Go, etc.).
Your container runs inside Docker's internal network. Traefik reaches it by its container IP — not 127.0.0.1. That's why binding to localhost breaks things: 127.0.0.1 inside the container is the container itself, not Traefik. Binding to 0.0.0.0 accepts connections from any network interface, including the one Traefik uses.
The PORT environment variable
Vessl injects a PORT environment variable into every app container. Your app should read this variable and listen on it. In most cases the value matches what you configured in the wizard, but always read the variable rather than hardcoding the number — this makes your app portable and avoids silent port mismatches.
Framework examples
Node.js / Express
const port = process.env.PORT || 3000;
app.listen(port, '0.0.0.0');
Node.js / Fastify
await app.listen({ port: parseInt(process.env.PORT ?? '3000'), host: '0.0.0.0' });
Python / Gunicorn (Django, Flask)
gunicorn myapp.wsgi:application --bind 0.0.0.0:${PORT:-8000}
Python / Uvicorn (FastAPI)
uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}
Go (net/http)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.ListenAndServe("0.0.0.0:"+port, handler)
Ruby on Rails (Puma)
Puma reads PORT automatically when started via rails server or bundle exec puma. No change needed — just make sure you're not overriding the port in config/puma.rb.
PHP / Laravel
Laravel with FrankenPHP or Octane reads PORT automatically. If you're using a standard nginx+php-fpm setup via Dockerfile, set the nginx listen directive to ${PORT:-8080} and expose that port.
Static sites
Static sites deployed via Vessl don't need to handle ports — they're served by an nginx container that Vessl manages. The port binding is handled for you.
Checking your app's binding
After a failed health check, use the Console tab to inspect what your app is actually listening on:
ss -tlnp
# or
netstat -tlnp
If you see 127.0.0.1:<port> — that's the problem. Change your binding to 0.0.0.0.
If you see nothing at all, the app crashed on startup. Check the Logs tab for the error.
Changing the declared port
If your app listens on a non-default port (e.g. 8080 instead of 3000), update the Port field in Settings → Runtime & Release. Vessl tells Traefik which port to forward to — if they don't match, the health check will time out even if the app itself is healthy.
- App listens on
0.0.0.0, not127.0.0.1orlocalhost - App reads
process.env.PORT/$PORT, not a hardcoded number - Port in app settings matches what the app actually listens on
- App starts in time — health check allows up to 60 seconds by default