Environment Variables and Saving Your Data to a File

Node.js and Express: Build a Back End API from Scratch · Data, Login and Going Live · 8 min read · Updated

Settings and data outside the code

  1. Settings and secrets live in a .env file that is not committed to git.
  2. Node loads the file and your code reads the values from process.env.
  3. The server uses the settings and calls the store to work with tasks.
  4. The store saves the tasks to a file after every change, so they survive a restart.

Two problems remain in the task API. The settings, such as the port number, are written inside the code, and the data vanishes every time the server restarts. In this lesson you will fix both. First you will learn about environment variables, the standard way to give a program its settings and secrets. Then you will move the data into a small store that saves tasks to a JSON file.

Why settings do not belong in code

Your program runs in different places: your laptop, a test machine and a live server. Each place needs different values: the port, the address of the database, the secret used for logins. If these values sit inside your code, you must edit the code for each place, and passwords end up in git, where many people can see them and where they stay in the history forever. The fix is to keep settings outside the code, in the environment, and have the code read them.

process.env

Every running program has a set of environment variables: named values given to it by the system that starts it. In Node.js they are in the object process.env. All values are strings, or undefined when missing. Read the port like this.

const port = Number(process.env.PORT) || 3000;

The Number(...) converts text to a number. If PORT is not set, Number(undefined) is NaN, which counts as false, so || 3000 supplies the default. Hosting services and process managers often set PORT for you, which is why the code should always read it.

A .env file

Typing variables in the terminal for every run is tedious. The usual solution is a text file named .env in the project folder, with one NAME=value per line. Recent versions of Node.js can load it directly with the flag --env-file=.env, which is what the start and dev scripts from lesson 2 use. You do not need an extra package.

# .env  (never commit this file)
PORT=3100
JWT_SECRET=change-me-to-a-long-random-string
DATA_FILE=./data/tasks.json
  • Add .env to .gitignore now, before your first commit.
  • Commit a copy named .env.example with the variable names and harmless sample values, so other people know what to set.
  • No spaces around =, and no quotes needed for simple values.
  • On a live server, do not upload a .env file at all if your host has a place to set environment variables. Use that instead.

Fail early when something is missing

If a required setting is missing, the program should stop at start-up with a clear message, and not fail in a confusing way later. You will see this in the final server.js in lesson 15, where a missing or short JWT_SECRET stops the server.

A store that saves to a file

Now the data. We move all work with tasks into a separate module, a store. The routes will call the store and will not care how data is kept. Today the store uses a file. In the next lesson, you can replace it with a database without changing the routes much. That is why separating the two is a good habit.

import { readFile, writeFile } from 'node:fs/promises';

// Keeps tasks in memory and, if you give it a file, saves after every change.
export function createStore(file) {
  let tasks = [];
  let nextId = 1;

  const ready = file
    ? readFile(file, 'utf8')
        .then((text) => {
          tasks = JSON.parse(text);
          nextId = tasks.reduce((max, t) => Math.max(max, t.id), 0) + 1;
        })
        .catch((err) => {
          if (err.code !== 'ENOENT') throw err;   // no file yet is fine, anything else is not
        })
    : Promise.resolve();

  const save = () => (file ? writeFile(file, JSON.stringify(tasks, null, 2)) : Promise.resolve());

  return {
    async list() { await ready; return tasks; },
    async get(id) { await ready; return tasks.find((t) => t.id === id); },
    async create(data) {
      await ready;
      const task = { id: nextId++, title: data.title, done: false, createdAt: new Date().toISOString() };
      tasks.push(task);
      await save();
      return task;
    },
    async update(id, data) {
      await ready;
      const task = tasks.find((t) => t.id === id);
      if (!task) return undefined;
      Object.assign(task, data);
      await save();
      return task;
    },
    async remove(id) {
      await ready;
      const before = tasks.length;
      tasks = tasks.filter((t) => t.id !== id);
      if (tasks.length === before) return false;
      await save();
      return true;
    },
  };
}

Take your time with this file. createStore(file) returns an object with five functions. The ready promise loads the file once at start-up. If the file does not exist, which is normal the first time, ENOENT is ignored and the list starts empty. Any other error is thrown, so a damaged file is not silently replaced. Each function starts with await ready, so requests that arrive during start-up wait for the data to load. After each change, save() writes the whole list to the file. The next id is the highest id so far plus one, so ids stay unique after a restart.

Now use the store in the server. The routes become short, because the store does the work and checkTask from lesson 10 does the validation. Put validate.js (from lesson 10) and store.js (this lesson's store) in the project folder next to server.js.

import express from 'express';
import { createStore } from './store.js';
import { checkTask } from './validate.js';

const port = Number(process.env.PORT) || 3000;
const store = createStore(process.env.DATA_FILE || './data/tasks.json');

const app = express();
app.use(express.json());

app.get('/api/tasks', async (req, res) => {
  res.json(await store.list());
});

app.post('/api/tasks', async (req, res) => {
  const { value, error } = checkTask(req.body);
  if (error) return res.status(400).json({ error });
  res.status(201).json(await store.create(value));
});

app.patch('/api/tasks/:id', async (req, res) => {
  const { value, error } = checkTask(req.body, { partial: true });
  if (error) return res.status(400).json({ error });
  const task = await store.update(Number(req.params.id), value);
  if (!task) return res.status(404).json({ error: 'Task not found' });
  res.json(task);
});

app.delete('/api/tasks/:id', async (req, res) => {
  const removed = await store.remove(Number(req.params.id));
  if (!removed) return res.status(404).json({ error: 'Task not found' });
  res.status(204).end();
});

app.listen(port, () => console.log(`Server running on port ${port}`));

Start the server with npm run dev, create a task, then stop the server and start it again. The task is still there, and data/tasks.json contains it.

curl -X POST http://localhost:3100/api/tasks -H "Content-Type: application/json" -d '{"title":"Saved"}'
curl http://localhost:3100/api/tasks
{"id":1,"title":"Saved","done":false,"createdAt":"2026-10-28T04:10:56.259Z"}
[{"id":1,"title":"Saved","done":false,"createdAt":"2026-10-28T04:10:56.259Z"}]

The dates in your output will show the time of your own request. Create the data folder first if it does not exist, because writeFile creates files but not folders.

Honest limits of a file store

  • It rewrites the whole file on every change, so it slows down as data grows.
  • If two changes arrive at the same moment, the second write can overwrite the first. Writes of tiny files are quick, so this is rare in a practice project, but it is a real risk on a busy server.
  • It lives on one machine. If you run several copies of the server, each has its own data.
  • There are no searches, indexes or relationships.
  • On many hosting services, the file system is wiped on every deploy. A file store is for learning and tiny tools. Anything that matters belongs in a database, which is the next lesson.

Your task

  1. Create .env and .env.example, and add .env to .gitignore.
  2. Use process.env.PORT in the server and start it on a different port by changing .env.
  3. Add store.js and move the routes to use it. Create a task, restart the server and check that it survived.
  4. Delete data/tasks.json and start the server. Check it starts with an empty list and does not crash.

How to check it worked

  • npm run dev starts on the port from .env.
  • A task created before a restart is still listed after it.
  • git status does not list .env.

Frequently asked questions

Why not put secrets directly in the code?

Code is shared and stored in git, so a secret in it can leak and stays in the history. Environment variables keep secrets outside the code and let each machine use its own values.

Do I need the dotenv package?

Not with current Node.js versions. The --env-file flag loads a .env file for you. The dotenv package is still common in older projects.