Skip to main content

Node and Go

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.

#What 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.

#What is writable

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.

#More than one web server

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.

#What starts it, and where it listens

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

#A binary that does not serve

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.

#Node

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

#Go

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.

#Cron

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.

#Health checks

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.

Next