b
LearnBun.org
Chapter 14 · Prepare for Production
Chapter 14

Prepare for Production

In Chapter 13, search and pagination landed. The notes app is feature-complete for Part II — you can sign up, log in, write, edit, delete, search, and page through your notes. Eighty-one tests confirm it.

The next step is getting it onto a real server. Before we touch a deployment platform, the app needs a small amount of work. Hardcoded values become environment variables. The session cookie learns the difference between development and production. A few package.json scripts get cleaned up. And we look at bun build once, just to see what it does.

Here is what we are building toward:

  • A .env file for local secrets and a .env.example checked into git
  • PORT and DB_PATH read from the environment, with sensible defaults
  • The session cookie gets secure: true when NODE_ENV is production
  • A production checklist you can run through every time
  • A short demo of bun build --compile producing a single-file binary

Where We Left Off

Open the project from Chapter 13 and start the server:

$ cd learn-bun/my-first-server
$ bun run dev

Sign in, write a note, search for it. Everything works.

Run the tests:

$ bun run test

All 81 tests should pass. If anything is broken, run git status to confirm a clean working directory from your Chapter 13 commit.

Environment Variables

The app already reads one environment variable. Open db.ts:

// db.ts (partial)
const db = new Database(process.env.DB_PATH ?? "notes.db", { create: true });

That line was written back in Chapter 7 and has been quietly correct ever since. process.env.DB_PATH reads the variable; ?? "notes.db" falls back to the default when it is unset. The tests have been using it the whole time — server.test.ts boots with DB_PATH=notes.test.db so the test suite and the dev server use different files.

That is the pattern we want everywhere a value should differ between dev and prod. The port is the next one.

Open index.ts and find the Bun.serve call:

// index.ts (partial)
const server = Bun.serve({
  port: 3000,
  routes: {
    ...
  },
  ...
});

Change port: 3000 to read from the environment:

// index.ts (partial)
const server = Bun.serve({
  port: Number(process.env.PORT) || 3000,
  routes: {
    ...
  },
  ...
});

Number(undefined) is NaN, and NaN || 3000 is 3000. Number("8080") is 8080, and 8080 || 3000 is 8080. The same shape we used for ?page= in Chapter 13 — fall back on anything that does not parse.

Save. Watch mode restarts. The server is still on 3000 because nothing set PORT. Stop the server and start it with a port:

$ PORT=4000 bun run dev

The startup log shows http://localhost:4000. Stop it. Start it again with bun run dev and you are back on 3000.

That is the whole environment-variables story for the app side. Two variables read, both with defaults. Production sets them; development does not.

A .env File

Typing PORT=4000 bun run dev every time gets old. The right place for local environment values is a .env file at the root of the project, and Bun loads it automatically — no library, no import.

Create .env:

# .env
PORT=3000
DB_PATH=notes.db

Save it. Start the server with plain bun run dev. The startup log shows port 3000 — but now the value came from .env, not from the default in index.ts. Change the port to 4000 in .env, save, watch mode restarts on port 4000. Change it back to 3000.

Two more files need to land before we move on.

Add .env to .gitignore:

# .gitignore (partial)
...
.env

The reason is the same reason we did not commit notes.db. .env is your machine's file. In a real app it will hold API keys, database passwords, and signing secrets — things that should never end up in a git history. We start the habit now, while the only value is the port.

Then create .env.example and commit it:

# .env.example
PORT=3000
DB_PATH=notes.db

The .example file shows a new developer (or your future self on a new machine) which variables exist. They copy .env.example to .env, fill in real values, and the app runs. Without it, the only way to learn what the app needs is to grep the source code for process.env..

Tidy the package.json Scripts

Open package.json. The scripts block has accumulated entries from earlier chapters:

{
  "name": "my-first-server",
  "module": "index.ts",
  "type": "module",
  "scripts": {
    "dev": "bun --watch index.ts",
	"test": "DB_PATH=notes.test.db bun test",
  },
  ...
}

Two more scripts earn their keep before we deploy. Add start and typecheck:

{
  "name": "my-first-server",
  "module": "index.ts",
  "type": "module",
  "scripts": {
    "dev": "bun --watch index.ts",
    "start": "bun index.ts",
	"test": "DB_PATH=notes.test.db bun test",
    "typecheck": "tsc --noEmit"
  },
  ...
}

start is the script every deploy platform looks for. Railway and Fly both run bun start (or bun run start) by default when they detect a Bun project. Unlike dev, it has no --watch — production restarts the whole process if a file changes, which would not be what we want. Production never changes files.

typecheck runs the TypeScript compiler in check-only mode. It surfaces the type errors bun run and bun test don't catch — the four bun-types gaps we have been ignoring, plus anything new that creeps in. It is the pre-flight check that runs before a deploy.

Run it now:

$ bun run typecheck

You should see the same eight errors we have been carrying since Chapter 10: three req.params errors in routes.ts, four req.cookies errors in users.ts, and one string | undefined error in server.test.ts. All bun-types gaps, none of them runtime bugs. The point is not that the count is zero — the point is that the count does not grow. A new type error in a new chapter would show up here.

A Production-Only Cookie Flag

The session cookie has been the same in dev and prod since Chapter 10:

// users.ts (partial)
req.cookies.set("session_id", session.id, {
  httpOnly: true,
  sameSite: "lax",
  path: "/",
  maxAge: session.expires_at - Math.floor(Date.now() / 1000),
});

httpOnly keeps JavaScript on the page from reading it. sameSite: "lax" keeps it from being sent on cross-site requests. Both are on in every environment.

The flag missing from that list is secure: true, which tells the browser "only send this cookie over HTTPS." In production it is the right answer. In development it is the wrong answer — localhost is HTTP, so a secure cookie never gets sent and you can't log in.

The fix is one line. Open users.ts and update createLogin:

// users.ts (partial)
const isProduction = process.env.NODE_ENV === "production";

req.cookies.set("session_id", session.id, {
  httpOnly: true,
  sameSite: "lax",
  secure: isProduction,
  path: "/",
  maxAge: session.expires_at - Math.floor(Date.now() / 1000),
});

NODE_ENV is the conventional name. Bun reads it, Node reads it, every deploy platform sets it to production automatically. We do not invent a new variable — we use the one the ecosystem already agrees on.

secure: false in development means the cookie still works over localhost. secure: true in production means a stolen HTTP request can't carry the cookie out. One line, two correct behaviors.

Save. Watch mode restarts. Log out, log back in. Everything still works because nothing set NODE_ENV and isProduction is false.

You may have noticed NODE_ENV is not in .env or .env.example. That is deliberate. Locally, we want NODE_ENV to be unset so the dev server defaults to development mode — secure: false, friendly errors, watch mode. If .env ever contained NODE_ENV=production, we would silently run the local server in production mode and waste an afternoon wondering why login broke. The platform sets NODE_ENV=production for us when we deploy in Chapter 15; we never type it outside the one-off demonstration below.

To prove the production branch, stop the server and start it with the variable set:

$ NODE_ENV=production bun start

Try to log in. The form submits, the redirect runs, but the home page renders the logged-out state — the browser refused the cookie because it came over HTTP with secure: true. That is the production behavior working as intended; in production the request would have been HTTPS and the cookie would set fine.

Stop the server. Start it normally with bun run dev. Log in. The cookie sets, the home page shows your notes.

The Production Checklist

A checklist is a small thing, but it is the difference between "I think it works" and "I know it works." Run through this every time before you deploy.

1. Tests pass.

$ bun run test

All 81 tests green. If even one is red, the deploy waits.

2. Types are clean.

$ bun run typecheck

The same eight known errors, no new ones.

3. The server starts with start, not dev.

$ bun start

The startup log shows the URL. Ctrl-C to stop.

4. The production cookie branch works.

$ NODE_ENV=production bun start

The server starts. Logging in over http://localhost should fail to set the cookie — same behavior we saw earlier. Stop the server.

5. The .env file is not committed.

$ git status

.env should not appear in tracked files. .env.example should.

6. The lockfile is committed.

$ ls bun.lock

The lockfile is what makes bun install deterministic on the deploy server. Without it, the platform might resolve slightly different package versions than your machine has. Commit it.

Five bun commands and one ls. The whole list takes under a minute to run.

A Bonus: bun build --compile

Everything above is what the app needs for production. This last section is a bonus — a thing Bun can do that other JavaScript runtimes can't, and that you should see once even though we won't use it for Railway.

Run this:

$ bun build --compile --outfile=notes-server ./index.ts

After a few seconds, an executable file called notes-server appears in the project root. It is roughly 90 MB. Inside it is your code, the Bun runtime, and everything node_modules needed at build time — packed into a single file you can run directly.

$ ./notes-server

The server starts. The log shows the URL. Open a browser, log in, write a note. The whole app runs out of one file.

That is what --compile does. It is genuinely useful when you want to ship a CLI tool, hand someone a binary, or run on a server with nothing installed. It is not what Railway and Fly want — those platforms expect a source tree and a start command, so they can build their own container and re-run bun install for caching. So we delete the binary:

$ rm notes-server

And go back to plain source code.

bun build also has a non---compile mode that bundles the source into one file without the runtime, for faster cold starts. For an app this size, on a platform that runs bun install for us, neither mode buys us anything. We mention it so you have seen what it does and know it exists when the day comes for a CLI tool or a single-file binary deploy.

Save and Commit

Check the status:

$ git status

You should see modified files (index.ts, users.ts, package.json, .gitignore) and one new tracked file (.env.example). .env itself should be ignored.

Add and commit:

$ git add .
$ git commit -m "Prepare for production: env vars, secure cookie, scripts"

Conclusion

The notes app is ready to deploy.

The key ideas from this chapter: environment variables are the line between dev and prod — anything that differs goes through process.env, with a default for development so the app runs out of the box. .env is for your machine, .env.example is for everyone else's, and .env belongs in .gitignore next to notes.db because secrets do not go in git. NODE_ENV === "production" is the standard gate for production-only behavior, and the session cookie's secure flag is the canonical example — wrong in dev, required in prod, one line of code to switch. The package.json start script is what platforms run, and it differs from dev by not watching for file changes. bun run typecheck is the pre-flight that catches what tests don't — type errors that compile fine but fail in a different shape. A production checklist is small, mechanical, and the difference between "I think it deploys" and "I know it deploys." And bun build --compile packs your code and the runtime into a single binary, which is a remarkable trick that you will not need for Railway but will remember the next time you ship a tool.

In Chapter 15, the app gets a public URL. Push to GitHub, point Railway at the repo, set the environment variables in the dashboard, and watch the build log. The next time you log in, you'll be doing it over HTTPS — and the secure cookie flag will finally have a reason to be there.

Bun for Beginners
Get the rest of the book.
Part II — eight chapters that turn the routing groundwork from Part I into a real Notes app. Part III ships in a few weeks (production deploy + ops).
·Forms, validation, and application state
·SQLite persistence and user accounts
·Sessions, login, and authorization
·Edit, delete, search, and pagination
Feedback