Deploy a Secure Task Manager API

Build and deploy a REST API with Fastify, SQLite, and Nginx on a live VPS.

Introduction

30 Second Summary

Every app you use daily (to-do lists, calendars, project boards) stores and retrieves your data through an API running on a server somewhere. Building one yourself is how you cross from "I can write JavaScript" to "I can build a real backend."

In this project, you will build a Task Manager API from scratch using Node.js and Fastify, then deploy it to a live server on the internet with production security.

What You'll Build

You will fire off a request from your phone or any browser and watch your API respond with real data from a database running on a server you configured yourself.

By the end of this project, you'll have:

  • A working REST API where you can create, retrieve, update, and delete tasks. JSON Schema validation automatically rejects malformed requests before your code even runs.
  • A live URL accessible from anywhere on the internet, backed by a SQLite database that keeps your data safe through server restarts and crashes.
  • A production-hardened server with an Nginx reverse proxy and rate limiting that blocks abusive traffic before it reaches your code.
  • Secret Mission: add a scheduled cron job that automatically deletes completed tasks older than 7 days.

Are there any prerequisites?

You should be comfortable with basic JavaScript (variables, functions, objects, arrays) and familiar with using a terminal. No prior backend or deployment experience is needed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. You're going to create a complete Task Manager API from scratch and deploy it to a live server that anyone on the internet can reach.

Set Up the Project Locally

You need a working local project before you can deploy anything. Starting from an empty folder means you will understand every file that ends up on your server.

In this step, you'll create the project folder, install the core dependencies, and write a minimal server that proves everything is wired up correctly.

In this step, get ready to:
  • Initialize the project folder and install dependencies.
  • Create the server entry point with a health check route.
  • Verify the server starts and responds to requests.
Initialize the project and install dependencies

Every Node.js project starts with a folder and a package.json file that tracks your project's metadata and dependencies.

  • In VS Code, open a terminal by clicking Terminal in the top menu bar, then clicking New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-manager-api
cd task-manager-api
  • Initialize a default package.json by running:
npm init -y

You should see output confirming that package.json was created with default values like name, version, and an empty test script.

What does npm init -y do?

The npm init command creates a package.json file, which is the manifest for any Node.js project. The -y flag accepts all defaults so you skip the interactive prompts.

  • Open the task-manager-api folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the task-manager-api folder on your Desktop, then click Open.

Why open the folder?

Opening the folder sets task-manager-api as your VS Code workspace. The Explorer sidebar shows your files, and any new terminal opens directly in this folder.

  • Open a new terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
  • In the Explorer sidebar, click package.json to open it.
  • Replace the entire contents of package.json with the following:
{
  "name": "task-manager-api",
  "version": "1.0.0",
  "description": "A secure task manager API built with Fastify",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "fastify": "^5.10.0",
    "@fastify/rate-limit": "^11.1.0",
    "better-sqlite3": "^11.8.1"
  }
}

What do these scripts and dependencies do?

  • start runs your server in production mode.
  • dev runs your server and auto-restarts it when you save changes, using Node's built-in --watch flag.
  • fastify is the web framework that handles HTTP requests and responses.
  • better-sqlite3 is the database driver you'll use to persist tasks.
  • @fastify/rate-limit prevents abuse by capping how many requests one IP can make per minute.
  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Install the dependencies by running this in your terminal:
npm install

You should see output showing packages being downloaded. A node_modules folder and a package-lock.json file appear in your Explorer sidebar.

Seeing an error during install?

  • If you see a network error, check your internet connection and try running npm install again.
  • On Windows, if better-sqlite3 fails to install, you may need build tools. Open a regular Command Prompt as Administrator and run C:\Program Files\nodejs\install_tools.bat, then retry npm install.
  • help me fix this npm install error
Create the server entry point

Your project needs a single file that starts the web server and defines what happens when someone sends a request. This file will grow as you add routes, but for now it only needs one health check endpoint.

  • Create a new file called server.js by clicking the new file icon at the top of the Explorer sidebar.
  • Paste the following code into server.js:
// Import the Fastify framework
const Fastify = require('fastify');

// Create a Fastify instance with logging enabled
const fastify = Fastify({ logger: true });

// Health check route - returns a simple status response
fastify.get('/', async (request, reply) => {
  return { status: 'ok' };
});

// Start the server on port 3000, accessible from any network interface
const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

What does this code do?

  • Fastify({ logger: true }) creates your server instance with built-in request logging. Every request gets printed to your terminal automatically.
  • fastify.get('/', ...) registers a route that responds to GET requests at the root URL. It returns a JSON object as the response.
  • host: '0.0.0.0' makes the server listen on all network interfaces. This is required later when you deploy to a remote server.
  • Save server.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Verify the server starts and responds

You wrote a Fastify server with logging enabled and one GET route at /. Before you run it, what do you think you'll see in the terminal?

  • Start the server by running:
node server.js

You should see a JSON log line that includes Server listening at http://localhost:3000 in the terminal output.

  • Open your browser and visit http://localhost:3000.

You should see {"status":"ok"} displayed in the browser. Your terminal also logs the incoming request automatically.

Not seeing the response?

  • If you see a module not found error for fastify, your dependencies did not install correctly. Run npm install again and check for errors.
  • If port 3000 is already in use, stop whatever is using it or change the port number in server.js.
  • Make sure you saved server.js before running the command. Unsaved changes are not picked up by Node.
  • help me debug my Fastify server not starting
  • Stop the server by pressing Ctrl+C in the terminal. You'll need the terminal free for the next step.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files, then move on to the next step.

ⓧ I'd like to double check the full code

Here's what your package.json should look like:

{
  "name": "task-manager-api",
  "version": "1.0.0",
  "description": "A secure task manager API built with Fastify",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "fastify": "^5.10.0",
    "@fastify/rate-limit": "^11.1.0",
    "better-sqlite3": "^11.8.1"
  }
}

Here's what your server.js should look like:

const Fastify = require('fastify');

const fastify = Fastify({ logger: true });

fastify.get('/', async (request, reply) => {
  return { status: 'ok' };
});

const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

Your local environment is ready. Next up, you'll build out the full CRUD routes that let you create, read, update, and delete tasks.

Build CRUD Routes with Schema Validation

Your server is running and responds to a health check. Now you need it to actually do something useful.

In this step, you will build a full set of CRUD routes (Create, Read, Update, Delete) for managing tasks. Each route includes a JSON Schema definition that tells Fastify exactly what data your API accepts. Invalid requests get rejected automatically before your handler code even runs.

In this step, get ready to:
  • Create a tasks route file with GET, POST, PUT, and DELETE endpoints.
  • Wire the routes into your server and test each endpoint.
  • See Fastify automatically reject invalid requests with zero custom code.
Create the tasks route file

Fastify encourages organizing routes into separate files. You will create a routes folder with a tasks.js file inside it.

  • Create the routes folder inside your task-manager-api project by running this command:
mkdir routes
  • Create a new file called tasks.js inside the routes folder and add the following code:
async function taskRoutes(fastify, options) {
  const db = options.db;

  const taskSchema = {
    type: 'object',
    properties: {
      id: { type: 'integer' },
      title: { type: 'string' },
      description: { type: 'string' },
      completed: { type: 'integer' },
      created_at: { type: 'string' },
      completed_at: { type: ['string', 'null'] }
    }
  };

  // GET /tasks - return all tasks
  fastify.get('/tasks', {
    schema: {
      response: {
        200: {
          type: 'array',
          items: taskSchema
        }
      }
    }
  }, async (request, reply) => {
    return [];
  });
}
module.exports = taskRoutes;

What does this code do?

This file exports an async function that Fastify can register as a plugin. The taskSchema object defines the shape of a task. Fastify uses this schema to validate responses automatically.

The GET /tasks route returns an empty array for now. The real database queries come in the next step. The schema tells Fastify that the response must be an array of task objects.

  • Save routes/tasks.js.
  • Confirm the file exists by running:
ls routes

You should see tasks.js listed in the output.

Wire the routes into server.js and test

Your route file exists, but the server does not know about it yet. You need to register it as a Fastify plugin.

  • Replace the entire contents of server.js with the following updated code:
const Fastify = require('fastify');
const taskRoutes = require('./routes/tasks');

const fastify = Fastify({ logger: true });

fastify.get('/', async (request, reply) => {
  return { status: 'ok' };
});

fastify.register(taskRoutes, { db: null });

const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

What changed?

Two lines are new. The require('./routes/tasks') import loads your route file. The fastify.register(taskRoutes, { db: null }) call mounts all the routes defined in that file onto the server.

The { db: null } option passes a placeholder database reference. You will swap this for a real database in the next step.

  • Save server.js.
  • Start your server by running:
node server.js
  • Open a second terminal window and test the new route by running:
curl http://localhost:3000/tasks

You should see [] returned. That empty array confirms your route is registered and responding.

Seeing an error instead of an empty array?

  • Make sure you saved both routes/tasks.js and server.js before starting the server.
  • Check that the routes folder is inside your task-manager-api folder, not somewhere else.
  • If you see Cannot find module './routes/tasks', confirm the filename is exactly tasks.js (not task.js).

Still stuck? Help me debug my route registration error.

Now add the remaining routes. Stop the server with Ctrl+C in the first terminal.

  • In routes/tasks.js, find the closing }); of the GET /tasks route. Add the following two routes below it, but above the closing } of the function:
  // GET /tasks/:id - return a single task by ID
  fastify.get('/tasks/:id', {
    schema: {
      params: {
        type: 'object',
        properties: { id: { type: 'integer' } },
        required: ['id']
      },
      response: { 200: taskSchema }
    }
  }, async (request, reply) => {
    reply.code(404).send({ error: 'Task not found' });
  });

  // POST /tasks - create a new task
  fastify.post('/tasks', {
    schema: {
      body: {
        type: 'object',
        required: ['title'],
        properties: {
          title: { type: 'string', minLength: 1 },
          description: { type: 'string' }
        }
      },
      response: { 201: taskSchema }
    }
  }, async (request, reply) => {
    reply.code(201).send({ id: 1, title: request.body.title, description: request.body.description || '', completed: 0, created_at: new Date().toISOString(), completed_at: null });
  });

What do these routes do?

GET /tasks/:id validates that the :id parameter is an integer. It returns 404 for now because there is no database yet.

POST /tasks uses a body schema with required: ['title']. This single line is what makes Fastify automatically reject requests missing a title. You wrote zero validation logic.

  • Save routes/tasks.js and restart the server:
node server.js
  • In your second terminal, test creating a task by running:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title": "Buy groceries"}'

You should see a JSON response with "id": 1 and "title": "Buy groceries". Your POST route is working.

Now stop the server again with Ctrl+C and add the final two routes.

  • In routes/tasks.js, add the following code below the POST route's closing }); and above the function's closing }:
  // PUT /tasks/:id - update an existing task
  fastify.put('/tasks/:id', {
    schema: {
      params: {
        type: 'object',
        properties: { id: { type: 'integer' } },
        required: ['id']
      },
      body: {
        type: 'object',
        properties: {
          title: { type: 'string', minLength: 1 },
          description: { type: 'string' },
          completed: { type: 'integer', minimum: 0, maximum: 1 }
        }
      },
      response: { 200: taskSchema }
    }
  }, async (request, reply) => {
    reply.code(404).send({ error: 'Task not found' });
  });
  // DELETE /tasks/:id - remove a task
  fastify.delete('/tasks/:id', {
    schema: {
      params: {
        type: 'object',
        properties: { id: { type: 'integer' } },
        required: ['id']
      }
    }
  }, async (request, reply) => {
    reply.code(404).send({ error: 'Task not found' });
  });

What do PUT and DELETE do?

PUT /tasks/:id accepts an optional title, description, or completed field in the body. The completed field is constrained to 0 or 1 by the schema. If someone sends 5, Fastify rejects it.

DELETE /tasks/:id only validates the :id parameter. Both return 404 until the database is connected in the next step.

  • Save routes/tasks.js and restart the server:
node server.js

You should see the server start successfully with no errors.

✔️ Awesome, I've got everything!

Great. Double check you saved routes/tasks.js and confirm the server is running without errors.

ⓧ I'd like to double check the full code

Your routes/tasks.js file should contain these sections in order. Scroll up and confirm each one is present:

  • The taskRoutes function wrapper with const db = options.db and the taskSchema object definition.
  • GET /tasks route returning an empty array.
  • GET /tasks/:id route with params validation.
  • POST /tasks route with body schema requiring title.
  • PUT /tasks/:id route with params and body validation.
  • DELETE /tasks/:id route with params validation.
  • The closing } and module.exports = taskRoutes; at the bottom.

Your server.js should have const taskRoutes = require('./routes/tasks'); near the top and fastify.register(taskRoutes, { db: null }); after the health check route.

Observe schema validation rejecting bad requests

Your routes are live. Now comes the real payoff of schema-first design. Fastify rejects malformed requests automatically, with no if statements or validation libraries required.

Before you run the next command, predict: what do you think happens when you POST to /tasks without a title field?

  • Send a request with a missing title field by running:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"description": "no title here"}'

You should see a 400 Bad Request response with a message indicating the required title property is missing. Fastify caught the problem before your handler code ran.

Why is this powerful?

You did not write a single line of validation logic. The required: ['title'] declaration in your schema is all Fastify needs. It compiles that schema into a high-performance validator using AJV under the hood.

This means fewer bugs, less boilerplate, and consistent error responses across every route.

  • Try one more test. Send a request where title is an empty string:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title": ""}'

You get another 400 error. The minLength: 1 constraint in your schema catches empty strings too.

Not seeing a 400 error?

  • Make sure you included -H "Content-Type: application/json" in your curl command. Fastify only validates the body when it knows the content type is JSON.
  • Confirm your POST route's body schema includes required: ['title'] inside the schema object.

Need help? Why isn't Fastify rejecting my invalid request?

Your API has five routes with built-in validation. The problem is that tasks vanish every time you restart the server. Next up, you will connect a real database so your data survives restarts.

Add a Persistent SQLite Database

Your API accepts requests and returns responses, but there is a critical flaw. Every task vanishes the instant you stop the server because the data lives only in JavaScript memory.

SQLite gives you a real SQL database stored in a single file. No separate database server to install, no credentials to manage. In this step, you will wire SQLite into your project so tasks survive restarts.

In this step, get ready to:
  • Create the database module with SQLite table definitions.
  • Wire the database into your routes with real SQL queries.
  • Verify tasks persist across server restarts.
Create the database module

Your database module will handle two responsibilities: opening (or creating) the SQLite file, and ensuring the tasks table exists with the right columns.

  • Create a new file called database.js in the root of your task-manager-api folder and paste the following code:
const Database = require('better-sqlite3');
const path = require('path');

function initDatabase() {
  // Open (or create) a file called tasks.db in the project folder
  const db = new Database(path.join(__dirname, 'tasks.db'));

  // WAL mode improves performance for concurrent reads
  db.pragma('journal_mode = WAL');

  // Create the tasks table if it doesn't already exist
  db.exec(`
    CREATE TABLE IF NOT EXISTS tasks (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      description TEXT DEFAULT '',
      completed INTEGER DEFAULT 0,
      created_at TEXT DEFAULT (datetime('now')),
      completed_at TEXT DEFAULT NULL
    )
  `);

  return db;
}

module.exports = { initDatabase };

What does this code do?

  • new Database(...) opens (or creates) a tasks.db file in your project folder. This single file IS your entire database.
  • journal_mode = WAL enables Write-Ahead Logging, which lets reads happen without blocking writes.
  • CREATE TABLE IF NOT EXISTS ensures the table is created on first run and skipped on subsequent runs. The columns match the shape your JSON Schema already defines.
  • Save database.js.

Now you need to connect this module to your server so the database is available to your routes.

  • In server.js, find the two lines at the top:
const Fastify = require('fastify');
const taskRoutes = require('./routes/tasks');
  • Replace them with these three lines, adding the database import between them:
const Fastify = require('fastify');
const { initDatabase } = require('./database');
const taskRoutes = require('./routes/tasks');
  • Next, find this line in server.js:
const fastify = Fastify({ logger: true });
  • Add this line directly below it to initialize the database:
const db = initDatabase();
  • Finally, find this line:
fastify.register(taskRoutes, { db: null });
  • Replace { db: null } with { db } so the real database instance is passed to your routes:
fastify.register(taskRoutes, { db });
  • Save server.js.
  • Start your server by running:
node server.js
  • Confirm the files that now exist in your project by opening a second terminal and running:
ls

You should see tasks.db listed alongside your other project files. SQLite created the database file automatically when the server started.

Server won't start?

  • If you see Cannot find module './database', make sure database.js is saved in the root of your task-manager-api folder (same level as server.js).
  • If you see an error about locating the bindings file, try running npm rebuild better-sqlite3 to recompile the native module.

Help me debug my server startup error.

  • Stop the server with Ctrl+C before continuing.
Replace in-memory handlers with SQL queries

Your route handlers currently return hardcoded responses. Now that a real database is connected, you will replace each handler body with SQL queries that read and write to tasks.db.

  • In routes/tasks.js, find the GET /tasks handler body:
  }, async (request, reply) => {
    return [];
  });
  • Replace the handler body so it queries all tasks from the database:
  }, async (request, reply) => {
    // Fetch all tasks, newest first
    const tasks = db.prepare('SELECT * FROM tasks ORDER BY created_at DESC').all();
    return tasks;
  });
  • Now find the GET /tasks/:id handler body:
  }, async (request, reply) => {
    reply.code(404).send({ error: 'Task not found' });
  });
  • Replace it with a lookup that returns the task or a 404:
  }, async (request, reply) => {
    // Look up a single task by its ID
    const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id);
    if (!task) {
      reply.code(404).send({ error: 'Task not found' });
      return;
    }
    return task;
  });

What does the ? placeholder do?

The ? in the SQL string is a parameterized placeholder. The value passed to .get(request.params.id) fills that slot safely, preventing SQL injection attacks. Never concatenate user input directly into SQL strings.

  • Next, find the POST /tasks handler body (the one that returns a hardcoded object):
  }, async (request, reply) => {
    reply.code(201).send({ id: 1, title: request.body.title, description: request.body.description || '', completed: 0, created_at: new Date().toISOString(), completed_at: null });
  });
  • Replace it with code that inserts a row and returns the new task:
  }, async (request, reply) => {
    const { title, description = '' } = request.body;
    // Insert the new task into the database
    const result = db.prepare(
      'INSERT INTO tasks (title, description) VALUES (?, ?)'
    ).run(title, description);
    // Fetch the newly created task to return it
    const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid);
    reply.code(201).send(task);
  });

What is lastInsertRowid?

When you .run() an INSERT statement, better-sqlite3 returns an object with lastInsertRowid. This is the auto-generated id of the row you just created. You use it to fetch the complete task (including its created_at timestamp) back from the database.

  • Save routes/tasks.js and start your server:
node server.js
  • In your second terminal, create a task by running:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title": "Buy groceries"}'

You should see a JSON response with your task, including a real id and a created_at timestamp generated by SQLite.

  • Retrieve all tasks to confirm it was stored:
curl http://localhost:3000/tasks

You should see an array containing the task you just created.

  • Stop the server with Ctrl+C.

Now you need to wire up the remaining two handlers: PUT for updates and DELETE for removal.

  • In routes/tasks.js, find the PUT /tasks/:id handler body:
  }, async (request, reply) => {
    reply.code(404).send({ error: 'Task not found' });
  });
  • This is the first of two identical handler bodies that just return 404. Replace the PUT handler (the one after the PUT schema definition) with this update logic:
  }, async (request, reply) => {
    const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id);
    if (!existing) {
      reply.code(404).send({ error: 'Task not found' });
      return;
    }

    const { title, description, completed } = request.body;
    // Keep existing values for any fields not provided
    const newTitle = title !== undefined ? title : existing.title;
    const newDescription = description !== undefined ? description : existing.description;
    const newCompleted = completed !== undefined ? completed : existing.completed;
    // Record when a task is first marked complete
    const completedAt = (newCompleted === 1 && existing.completed === 0)
      ? new Date().toISOString()
      : existing.completed_at;

    db.prepare(
      'UPDATE tasks SET title = ?, description = ?, completed = ?, completed_at = ? WHERE id = ?'
    ).run(newTitle, newDescription, newCompleted, completedAt, request.params.id);

    const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id);
    return task;
  });

Why check each field for undefined?

A PUT request might only send the fields the user wants to change. If title is not in the request body, you keep the existing title rather than overwriting it with undefined.

The completedAt logic records the exact moment a task transitions from incomplete to complete. If it was already complete, the original timestamp is preserved.

  • Find the remaining DELETE /tasks/:id handler body (the second reply.code(404) block, after the DELETE schema):
  }, async (request, reply) => {
    reply.code(404).send({ error: 'Task not found' });
  });
  • Replace it with code that deletes the task or returns 404:
  }, async (request, reply) => {
    const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(request.params.id);
    if (!existing) {
      reply.code(404).send({ error: 'Task not found' });
      return;
    }
    // Remove the task permanently
    db.prepare('DELETE FROM tasks WHERE id = ?').run(request.params.id);
    reply.code(204).send();
  });
  • Save routes/tasks.js.

Seeing a syntax error when starting the server?

  • Make sure you replaced only the handler body (the async (request, reply) => { ... } part) and did not accidentally delete the closing }); that ends each route registration.
  • Check that you did not remove the module.exports = taskRoutes; line at the bottom of the file.

Help me fix a syntax error in my routes file.

✔️ Awesome, I've got everything!

Great. Double check you have saved both routes/tasks.js and server.js.

ⓧ I'd like to double check the full code

Here is the complete database.js:

const Database = require('better-sqlite3');
const path = require('path');

function initDatabase() {
  const db = new Database(path.join(__dirname, 'tasks.db'));

  db.pragma('journal_mode = WAL');

  db.exec(`
    CREATE TABLE IF NOT EXISTS tasks (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      description TEXT DEFAULT '',
      completed INTEGER DEFAULT 0,
      created_at TEXT DEFAULT (datetime('now')),
      completed_at TEXT DEFAULT NULL
    )
  `);

  return db;
}

module.exports = { initDatabase };

Here is the complete server.js:

const Fastify = require('fastify');
const { initDatabase } = require('./database');
const taskRoutes = require('./routes/tasks');

const fastify = Fastify({ logger: true });

const db = initDatabase();

fastify.get('/', async (request, reply) => {
  return { status: 'ok' };
});

fastify.register(taskRoutes, { db });

const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

For routes/tasks.js, scroll up and confirm each handler matches the replacement code you pasted. In order, verify:

  • GET /tasks uses db.prepare('SELECT * FROM tasks ORDER BY created_at DESC').all().
  • GET /tasks/:id uses .get(request.params.id) with a 404 fallback.
  • POST /tasks uses INSERT and returns the new row via lastInsertRowid.
  • PUT /tasks/:id merges provided fields with existing values and updates.
  • DELETE /tasks/:id checks existence, deletes, and sends 204.
  • module.exports = taskRoutes; is still at the bottom.
Test persistence across restarts

The whole point of adding a database is that tasks survive a server restart. Before you run the test, think about what you expect to happen.

  • Start the server:
node server.js
  • In your second terminal, create two tasks:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title": "Learn SQL", "description": "Parameterized queries"}'
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title": "Deploy to production"}'
  • Stop the server with Ctrl+C.

Before you restart, do you think your tasks will still be there? Why or why not?

  • Restart the server:
node server.js
  • Retrieve your tasks:
curl http://localhost:3000/tasks

You should see both tasks returned with their original IDs and timestamps intact. Your data survived the restart because it lives in tasks.db on disk, not in JavaScript memory.

  • Test the update endpoint by marking the first task as complete:
curl -X PUT http://localhost:3000/tasks/1 -H "Content-Type: application/json" -d '{"completed": 1}'

You should see the task returned with completed set to 1 and a completed_at timestamp.

  • Test the delete endpoint:
curl -X DELETE http://localhost:3000/tasks/2
  • Confirm it is gone by fetching all tasks:
curl http://localhost:3000/tasks

You should see only one task remaining. Your full CRUD API is now backed by a persistent database.

Getting 'Task not found' on PUT or DELETE?

  • If you deleted tasks.db at any point, your previous task IDs no longer exist. Create a fresh task with POST and use the returned id in your PUT or DELETE request.
  • Make sure you replaced the correct handler body. The PUT handler is the one after the PUT schema (with title, description, and completed in body properties). The DELETE handler is the last route in the file.

Help me debug my task routes.

Your API now has real persistence. Tasks survive restarts, updates are tracked, and every query is protected against injection. Next up, you will deploy this API to a live server so anyone on the internet can reach it.

Deploy to a Live VPS

Your API works perfectly on your own machine, but nobody else can reach it. If you share http://localhost:3000 with a friend, they will get nothing.

Deploying to a VPS (Virtual Private Server) gives your API a public IP address that anyone on the internet can reach. In this step, you will provision a real Linux server on DigitalOcean, copy your project to it, and keep it running permanently with a process manager.

In this step, get ready to:
  • Create a DigitalOcean Droplet and SSH into it.
  • Get your project files onto the server and install dependencies.
  • Run your API with PM2 so it stays alive and auto-restarts on reboot.
Create a DigitalOcean Droplet and SSH in

A Droplet is DigitalOcean's name for a virtual server. You will create one running Ubuntu and connect to it over SSH from your terminal.

  • Open the DigitalOcean Control Panel at https://cloud.digitalocean.com and log in.
  • Click the green Create button at the top of the page.
  • Click Droplet in the menu that appears.
  • In the Choose an image section, select Ubuntu 24.04 (LTS).
  • In the Choose Size section, select the $6/month plan (1 GiB RAM, 1 vCPU, 25 GiB SSD).
  • In Choose Region, pick the datacenter closest to you.
  • In Choose Authentication Method, select Password and create a strong root password.
  • Scroll down to Finalize Details and enter task-manager-api as the hostname.
  • Click Create Droplet.

A progress bar appears while your server is provisioned. Once ready, the Control Panel displays your Droplet's public IP address.

  • Copy the IP address displayed next to your new Droplet.
  • Open a terminal on your local machine and connect by running:
ssh root@[[DROPLET_IP="your Droplet IP address"]]
  • When prompted, type yes to accept the fingerprint, then enter the root password you created.

You should now see a command prompt like root@task-manager-api:~# confirming you are inside your remote server.

Connection refused or timed out?

Wait 60 seconds after creating the Droplet. DigitalOcean needs time to finish provisioning the server.

Double-check the IP address you copied matches the one in the Control Panel. Help me connect to my DigitalOcean Droplet via SSH.

Now install Node.js on the server by running these two commands:

sudo apt update && sudo apt install -y nodejs npm
  • Verify the installation by running:
node -v

You should see a version number printed. This confirms Node.js is ready on your server.

Why install from the default Ubuntu repository?

Ubuntu 24.04 ships a recent Node.js version in its default package repository. For this project, the default version is sufficient and avoids extra setup steps with third-party PPAs.

Get your project files onto the server

Your code lives on your local machine. You need to transfer it to the Droplet so the server can run it.

You will use scp (secure copy), which transfers files over SSH.

  • Open a new terminal tab on your local machine (not the SSH session).
  • Navigate to the parent directory of your project by running:
cd ~/Desktop
  • Copy your project folder to the server by running:
scp -r task-manager-api root@[[DROPLET_IP="your Droplet IP address"]]:/root/

What does this command do?

  • scp -r recursively copies an entire folder over SSH.
  • task-manager-api is your local project folder.
  • root@<IP>:/root/ places it in the home directory on your server.
  • Enter your root password when prompted. You will see file names printing as they transfer.

Now switch back to your SSH session and install the project dependencies.

  • In your SSH terminal, move into the project folder by running:
cd /root/task-manager-api
  • Install dependencies by running:
npm install

You should see npm download and install the packages listed in package.json.

Seeing build errors for better-sqlite3?

The better-sqlite3 package requires compilation tools. Install them by running sudo apt install -y build-essential then re-run npm install.

Help me fix better-sqlite3 build errors on my Ubuntu server.

  • Quickly verify the server can start by running:
node server.js

You should see Server listening at http://0.0.0.0:3000 in the output. Press Ctrl+C to stop it.

Run the API with PM2 for production

Running node server.js directly means the app dies the moment you close your SSH session. PM2 is a process manager that keeps your app running in the background, restarts it if it crashes, and brings it back after a server reboot.

  • Install PM2 globally by running:
npm install pm2 -g
  • Start your API with PM2 by running:
pm2 start server.js --name task-manager-api

PM2 prints a table showing your app in online status.

What does this command do?

  • pm2 start server.js launches your app as a background daemon.
  • --name task-manager-api gives the process a friendly name so you can manage it easily.
  • If the app crashes, PM2 automatically restarts it.
  • Configure PM2 to start on system boot by running:
pm2 startup

PM2 prints a command to copy and paste. Since you are already running as root, it may configure itself automatically. If it prints a command starting with sudo env PATH=..., copy and run that exact command.

  • Save the current process list so PM2 knows what to restart on boot:
pm2 save
  • Confirm your app is running with:
pm2 list

You should see a table with task-manager-api showing status online.

Status showing errored?

Run pm2 logs to see what went wrong. The most common cause is running pm2 start from the wrong directory. Make sure you are inside /root/task-manager-api before starting.

Help me debug why PM2 shows my app as errored.

Before you run the verification, what do you think will happen when you curl your server's IP on port 3000 from your local machine?

  • Open a terminal on your local machine and test the live API by running:
curl http://[[DROPLET_IP="your Droplet IP address"]]:3000/tasks

You should see [] returned. That empty array is coming from your live server, accessible from anywhere on the internet.

Your API is live on the internet. Next up, you will harden it for production with Nginx as a reverse proxy and rate limiting to prevent abuse.

Harden for Production with Nginx and Rate Limiting

Your API is live on the internet, but it is completely exposed. Anyone who finds your server IP can hit port 3000 directly, and there is nothing stopping someone from flooding your API with thousands of requests per second.

In this step, you will put two layers of protection in front of your app. Nginx acts as a reverse proxy that receives all incoming traffic on port 80 and forwards it to your app internally. Rate limiting caps how many requests a single IP address can make per minute.

In this step, get ready to:
  • Install Nginx and configure it as a reverse proxy forwarding port 80 traffic to your app.
  • Enable rate limiting in your Fastify app to reject abusive traffic.
  • Lock down port 3000 with a firewall so the only way in is through Nginx.
Install Nginx and configure it as a reverse proxy

Right now, visitors access your API by hitting port 3000 directly. A reverse proxy sits in front of your app and receives all incoming HTTP traffic on the standard web port (80), then quietly forwards it to your Node.js app behind the scenes.

  • In your SSH session on the Droplet, install Nginx by running:
apt update && apt install nginx -y
  • Confirm Nginx is running by checking its status:
systemctl status nginx

You should see a green "active (running)" status in the output.

Nginx not running?

If the status shows "inactive" or "failed", try starting it manually with systemctl start nginx. If that fails, check for errors with journalctl -u nginx.

Help me troubleshoot Nginx not starting on my Ubuntu server.

  • Create a new Nginx configuration file for your API by running:
nano /etc/nginx/sites-available/task-manager
  • Paste the following server block configuration into the file:
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

What does this configuration do?

  • listen 80 tells Nginx to accept traffic on port 80, the standard HTTP port.
  • server_name _ is a catch-all that matches any hostname (since you do not have a domain name pointed at this server).
  • proxy_pass http://localhost:3000 forwards every request to your Fastify app running on port 3000.
  • The proxy_set_header lines preserve the original request headers so your app sees the real client information.
  • Save the file and exit nano (press Ctrl+X, then Y, then Enter).
  • Enable the site by creating a symlink to the sites-enabled directory:
ln -s /etc/nginx/sites-available/task-manager /etc/nginx/sites-enabled/
  • Remove the default Nginx site to avoid conflicts:
rm /etc/nginx/sites-enabled/default
  • Test that your configuration has no syntax errors:
nginx -t

You should see syntax is ok and test is successful in the output.

  • Restart Nginx to apply the new configuration:
systemctl restart nginx

Before you test this, what do you think will happen when you visit your server IP on port 80 without the :3000 suffix?

  • From your local machine, test the reverse proxy by running:
curl http://[[DROPLET_IP="your server IP address"]]/tasks

You should see your tasks array returned. Nginx is now forwarding port 80 traffic to your Fastify app on port 3000.

Getting a 502 Bad Gateway?

This means Nginx cannot reach your app on port 3000. Make sure PM2 is still running your app by checking pm2 status on the Droplet. If the app is stopped, restart it with pm2 start server.js.

Help me fix a 502 Bad Gateway error with Nginx and my Node.js app.

Enable rate limiting to reject abusive traffic

Your API is now reachable through Nginx, but anyone can still send unlimited requests. The @fastify/rate-limit plugin caps how many requests a single IP can make within a time window.

  • On the Droplet, open server.js in the project directory:
cd /root/task-manager-api && nano server.js
  • Find this line near the top of the file:
const db = initDatabase();
  • Add the rate-limit registration directly below it, so the section now looks like this:
const db = initDatabase();

fastify.register(require('@fastify/rate-limit'), {
  max: 100,
  timeWindow: '1 minute'
});

What does this code do?

This registers the rate-limit plugin with Fastify. The max: 100 setting means each unique IP address can make at most 100 requests per minute. After that, Fastify automatically responds with a 429 status code and rejects additional requests until the window resets.

  • Save the file and exit nano.
  • Restart the app with PM2 to pick up the changes:
pm2 restart server

What do you think will happen if you send more than 100 requests in under a minute from the same IP?

  • From your local machine, test rate limiting by sending 101 rapid requests:
for i in $(seq 1 101); do curl -s -o /dev/null -w "%{http_code}\n" http://[[DROPLET_IP="your server IP address"]]/tasks; done

You should see a stream of 200 responses, and then the final request returns 429. That 429 proves rate limiting is working.

All 101 requests return 200?

Make sure you saved server.js and ran pm2 restart server. You can verify the app restarted by checking pm2 logs --lines 5 for recent startup messages.

Help me figure out why my Fastify rate limiting is not working.

Block direct access to port 3000 with UFW firewall

Right now, both port 80 (through Nginx) and port 3000 (directly) are accessible from the internet. Someone could bypass Nginx entirely by hitting port 3000. A firewall closes that loophole.

  • On the Droplet, allow SSH traffic first (critical to avoid locking yourself out):
ufw allow OpenSSH
  • Allow HTTP traffic through Nginx:
ufw allow 'Nginx HTTP'
  • Explicitly deny external access to port 3000:
ufw deny 3000
  • Enable the firewall by running:
ufw --force enable

Why allow OpenSSH before enabling?

UFW blocks all incoming traffic by default once enabled. If you enable it without allowing SSH first, you immediately lose your SSH connection and cannot get back in. Always allow SSH before running ufw enable.

  • Verify the firewall rules are active:
ufw status

You should see OpenSSH, Nginx HTTP, and 3000 (DENY) listed in the output.

Now for the real test. What do you expect will happen if you try to curl port 3000 directly from your local machine?

  • From your local machine, try accessing port 3000 directly:
curl http://[[DROPLET_IP="your server IP address"]]:3000/tasks

The connection should hang and eventually time out. Port 3000 is now blocked from external access.

  • Confirm that port 80 still works:
curl http://[[DROPLET_IP="your server IP address"]]/tasks

Your tasks array returns. The only way to reach your API from the outside is through Nginx on port 80.

Port 80 also not responding?

Check that your ufw allow 'Nginx HTTP' rule is in place by running ufw status on the Droplet. If it is missing, add it and then run ufw reload.

Help me fix my UFW firewall rules so Nginx can serve traffic on port 80.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js on the Droplet and that PM2 is running the updated version.

ⓧ I'd like to double check the full code

Here is what server.js should look like on the Droplet at this point:

const Fastify = require('fastify');
const { initDatabase } = require('./database');
const taskRoutes = require('./routes/tasks');

const fastify = Fastify({ logger: true });

const db = initDatabase();

fastify.register(require('@fastify/rate-limit'), {
  max: 100,
  timeWindow: '1 minute'
});

fastify.get('/', async (request, reply) => {
  return { status: 'ok' };
});

fastify.register(taskRoutes, { db });

And the startup function at the bottom:

const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

Your API is now production-hardened. Traffic flows through Nginx, abusive IPs get rate-limited, and the firewall blocks anyone trying to sneak around the proxy. Next up, the Secret Mission adds automated cleanup to keep your database lean.

Secret mission

Add a Cron Job to Auto-Delete Old Tasks

Production APIs accumulate stale data over time. In this secret mission, you will add a scheduled background job that automatically deletes completed tasks older than 7 days. This teaches you a pattern used everywhere from email cleanup to billing reconciliation.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses a DigitalOcean Droplet that bills at $6.00/month (per-second billing), so leaving it running does cost money.

Cost warning

Your DigitalOcean Droplet bills at $6.00/month even when powered off. The only way to stop charges completely is to destroy the Droplet. If you are done with this project, choose the Delete tab below to avoid ongoing costs.

Resources you used:

  • 1 DigitalOcean Droplet (Ubuntu 24.04, $6/month plan with PM2, Nginx, and your API code).
  • Local task-manager-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want your API to stay live at your Droplet's IP address.

  • Your API remains accessible on port 80 through Nginx.
  • PM2 keeps your server running and auto-restarts it if it crashes.
  • You will be billed approximately $0.20/day for the Droplet.

Pause - I'll come back to this later

DigitalOcean does not support pausing Droplets. Powered-off Droplets still incur the full $6.00/month charge because the disk and IP address remain reserved.

  • If you want to preserve your work without ongoing costs, take a snapshot of the Droplet before destroying it. Snapshots cost $0.06/GiB/month (much less than a running Droplet).
  • To snapshot: open your Droplet in the DigitalOcean Control Panel, click the Snapshots tab, and click Take live snapshot.
  • You can recreate the Droplet later from that snapshot.

Delete - I don't want to use this again

Remove all project resources and stop billing immediately. This is permanent and cannot be undone.

Destroy the DigitalOcean Droplet:

  • Open the DigitalOcean Control Panel at cloud.digitalocean.com.
  • Find your Droplet in the list, click its More menu, and choose Destroy.
  • Click Destroy this Droplet and backups.
  • Confirm the destruction in the confirmation window by clicking Confirm. Billing stops immediately.

Delete the local project folder:

  • On macOS/Linux, open your terminal and run:
rm -rf ~/Desktop/task-manager-api
  • On Windows, open PowerShell and run:
Remove-Item -Recurse -Force ~\Desktop\task-manager-api

Nice Work!

Nice Work!

Nice work! You just built a production-grade REST API from scratch, backed it with a real database, and deployed it to a live server that anyone on the internet can reach.

You learned how to:

  • Build a full CRUD API with Fastify and JSON Schema validation that automatically rejects malformed requests before your handler code ever runs.
  • Persist data in a SQLite database using better-sqlite3, so tasks survive server restarts without needing a separate database server.
  • Deploy to a live DigitalOcean server with PM2 process management, Nginx reverse proxy, rate limiting, and UFW firewall rules to harden the API for production traffic.
  • Secret Mission: Added a scheduled cron job with node-cron to automatically clean up completed tasks older than 7 days.

Ready to quiz yourself?