Test and ship
Prepare a compatible release
Configure, shut down, version, document, and review the API as a product contract rather than just a running server.
The API works on your laptop. Shipping it means it also has to work on a machine you don’t watch, restart without losing requests, and keep its promises to clients you don’t control. Four things get us there.
Read configuration, then refuse to start
Port, database path, allowed origins: none of them belong in the code. Read them from the environment and check them at startup:
const port = Number(process.env.PORT ?? 3000)
const databasePath = process.env.DATABASE_PATH
if (!databasePath) {
console.error('DATABASE_PATH is required')
process.exit(1)
}
A process that dies in the first second with a clear message is a good process. One that fails on the first POST is a bad one. Commit a .env.example listing every variable.
Two health routes, not one
GET /health answers “is the process alive?” and returns 200 as long as the event loop runs. GET /ready answers “should this instance receive traffic?” and checks what a request needs, like a SELECT 1 against the database.
A new instance with a missing migration answers 503 on /ready, the load balancer never sends it traffic, and the old instance keeps serving. Keep both routes cheap, and never let them write anything.
Stop without dropping requests
When the platform wants the process gone, it sends SIGTERM. Exiting immediately cuts off requests in flight. The right sequence: fail readiness, stop accepting connections, finish active requests, close the database, exit.
let ready = true
const server = serve({ fetch: app.fetch, port })
app.get('/ready', c => ready ? c.text('ok') : c.text('shutting down', 503))
process.on('SIGTERM', () => {
ready = false
server.close(() => {
db.close()
process.exit(0)
})
setTimeout(() => process.exit(1), 10_000).unref()
})
server.close() stops new connections and waits for open ones. The timer is the deadline: if something hangs for ten seconds, exit anyway with a non-zero code.
Change the contract on purpose
Adding a field, a route or an optional parameter breaks nobody. Renaming a field, removing one, or changing a status does. Prefer the first kind. When a breaking change is unavoidable, publish a version boundary like /v2/books, keep the old path running for a while, and write down the migration steps.
The database has the same rule, because a code rollback doesn’t undo a migration. Use expand and contract: add the new column, deploy code that writes both, migrate the data, deploy code that reads the new column, then drop the old one. At every step the previous version still runs against the current schema.
The release record
Before the first deploy, write down what a release is: the Git commit, the migration version, the OpenAPI version, the curl requests that prove it’s up, the log query to watch, the rollback trigger, and who is on the hook. Then hand the list to someone else and have them run it. If they need to ask you a question, the list is missing a line.
Add /health and /ready, the signal handler, the .env.example, an OpenAPI export command, and the release checklist now. That’s the Books API done.
Lesson completed