Running an application that serves its own HTTP, and why there is no config file for it.
A Node or Go application serves its own HTTP and replaces PHP-FPM and nginx rather than joining them. In front of it is the platform's proxy, which terminates TLS, routes the hostname and compresses responses.
There is no bootstrap file for either, and that is not an omission. The PHP frameworks need one because each has its own idea of where configuration comes from; a Node or Go application reads the environment directly, which is what the platform already hands it. Shipping a file would be imposing a framework opinion on applications that do not have one.
What is worth knowing is which names to read.
PORT what to listen on — bind this, not a port of your choosing
DATABASE_URL one connection string, which is what most drivers take
REDIS_HOST the cache, when the stack runs one — with REDIS_PORT
VALLIC_PRIVATE_DIR a directory that outlives every release — where uploads go
VALLIC_LOG_DIR where log files go, if you write any, to be collected
DATABASE_URL exists for exactly this: pg.Pool({connectionString}),
sql.Open("postgres", url) and their equivalents all take a URL, and
assembling one from five parts is the glue nobody should be writing. The parts
are there too if you prefer them — see Variables.
Bind the port you are given, and bind it on all interfaces rather than
localhost: the proxy reaches your process across the container boundary, and
a server listening on 127.0.0.1 inside its own container is reachable by
nothing.
The release is mounted read-only while the application runs: what the
build produced is what serves, and nothing can change it in place. Anything
your application writes and expects to keep goes under VALLIC_PRIVATE_DIR
(private/ at the root of the release), which is kept outside the release and
linked back into every one. There is no public counterpart — nginx is not in
front of you, so what gets served from where is your application's decision.
A directory at a path of your own choosing works the same way when declared as a mount:
mounts:
- uploads
Output to stdout and stderr is collected without any of this — see Logs.
Once an environment has two, consecutive requests from one visitor can land on
different machines, so anything kept in a process's memory — express-session's
default store, an in-memory cache — is not there on the next request. Keep
sessions in Valkey, at REDIS_HOST and REDIS_PORT; connect-redis and its
equivalents take exactly those two.
Two keys, and both have defaults you can usually leave alone.
| Node | Go | |
|---|---|---|
| Default command | npm start |
none — start is required |
Default PORT |
3000 |
8080 |
start is the command that serves. It only applies to a runtime that is its
own web server, which is why no PHP framework has one — there, PHP-FPM is the
process whatever the code says.
port overrides the default. Setting it changes the PORT your application is
given and the port the platform expects to reach, together — which is the
reason to set it there rather than hard-coding a number in your code. If you
read PORT and never set port, everything already agrees.
start: node dist/server.js
port: 4000
Nothing requires start to listen. It is a command, and a command that
processes a queue, consumes a stream or sits in a loop is a perfectly good
one — leave health out and there is no check to fail, so the deploy
succeeds on the process staying up rather than on an HTTP answer.
What you get anyway is the environment's hostname pointed at that container, and with nothing listening it answers 502. Harmless, but it means the environment looks broken to anyone who visits it.
So a background process that belongs beside a site is better written as a worker, which is supervised the same way but has no port and no hostname attached to it:
workers:
- name: queue
command: bin/worker
Workers run your application's own image against the same release, so they need an application in the stack to run in — they are extra processes for a deployed application, not a way to deploy a process on its own.
version: 1
type: nodejs
runtime:
node: '24'
services:
- postgres: '18'
build:
# npm's download cache is provided. This is the bundler's build cache,
# which is what makes the second build of an unchanged app quick.
cache:
- .next/cache
steps:
- npm ci
- npm run build
- npm prune --omit=dev
deploy:
steps:
- 'npm run migrate'
on_failure: rollback
health:
path: /healthz
A database under services is a check, not a request: it has to be the one
the environment was created with, because a commit cannot add or change a
database. Other services, such as Valkey, are started by the next deploy if the
environment lacks them. The version after each name is required, and is what
the environment runs from the next deploy — see
Service upgrade before you change one that keeps data.
node_modules is the runtime rather than a build input, so the release keeps
it — npm prune at the end of the build drops what production does not need.
No start here, on purpose. Left unsaid, the image runs npm start, so a
project with a start script in its package.json needs nothing. Add one when
your entry point is something else:
start: node dist/server.js
version: 1
type: golang
runtime:
go: '1.27'
build:
steps:
- go build -o bin/server ./cmd/server
start: bin/server
health:
path: /healthz
start is required for Go. The image is the upstream Go image and has no
default worth running — nothing can guess that your binary is bin/server, so
leaving it out deploys a container that starts and immediately exits.
A Go build produces one binary and the release is that binary, which is why
there is nothing to prune and usually nothing to run at deploy time. If your
schema needs migrating, a deploy step running your own migration tool is the
place for it.
The language version is keyed by the language — node, go — whatever the
type says: nodejs, node, next and express all mean Node, and golang
and go both mean Go.
Nothing is scheduled for you. Neither runtime has one scheduler the platform could name, so periodic work is yours to declare, and runs in the application's container against the live release:
cron:
- name: digest
schedule: '0 * * * *'
command: node dist/jobs/digest.js
It runs on one machine only, however many web servers the environment has. For the same reason, restoring a backup clears no cache for you: the platform knows no command that would.
Worth setting for both. With health.path set, a deploy waits for that path to
answer before it counts as done, and on_failure: rollback puts the previous
release back if it never does. The platform also watches production and tells
you when it stops answering — see Notifications — and an
endpoint that checks the database is a far better signal than one that returns
200 because the process is alive.