🐳 Docker with Express and SQLite3
"But it works on my machine!" is the oldest excuse in software. Docker retires it for good. In this tutorial you'll package a real Express API and its SQLite database into a container that runs identically on your laptop, a teammate's PC, and a production server — no setup rituals, no version mismatches.
Reference & Extra Tutorials · Resources · Containerization
🎯 What This Covers
By the end of this tutorial, you will be able to:
- Explain what a container is and how it differs from a virtual machine
- Structure a small Express + SQLite3 project ready for containerization
- Write a
Dockerfileline by line and understand each instruction - Persist database data across container restarts using a volume
- Orchestrate the build and run with a
docker-compose.ymlfile - Build, start, test, and tear down the whole stack with single commands
Estimated Time: 75 minutes
Project: A containerized user-management API with a persistent SQLite database.
In This Tutorial
Why Containers?
Imagine moving house. Tossing everything loose into a truck is chaos — things break, nothing's labeled, unpacking is misery. Now picture packing into standardized shipping containers: each is self-contained, sealed, and moves cleanly between truck, ship, and doorstep. Docker does exactly this for software. Your app plus everything it needs to run — the right Node version, the exact dependencies, the config — gets sealed into one portable unit called a container.
A container isn't a whole virtual machine. A VM ships an entire guest operating system, so it's heavy and slow to boot. A container shares the host's kernel and packages only your app and its libraries, so it's small and starts in seconds.
Three words you'll use constantly. A Dockerfile is the recipe. Running docker build turns that recipe into an image — a read-only, shareable snapshot. Running docker run turns an image into a live container. One image can spawn many identical containers, the way one blueprint builds many identical houses.
📖 Image vs container
Think of an image as a class and a container as an instance of that class. The image is the template on disk; the container is a running copy with its own memory, processes, and (optionally) writable data.
Project Structure
Before writing any Docker file, lay out the app. Using the CLI skills from the previous tutorial, scaffold this tree:
mkdir -p user-api/src/routes user-api/data
cd user-api
touch src/index.js src/database.js src/routes/users.js
touch Dockerfile docker-compose.yml package.json schema.sql .dockerignore
The result:
user-api/
├── src/
│ ├── index.js # Express entry point
│ ├── database.js # SQLite connection
│ └── routes/
│ └── users.js # /api/users route handlers
├── data/ # SQLite file lives here (persisted via a volume)
├── schema.sql # table definitions
├── Dockerfile # how to build the image
├── docker-compose.yml # how to run it
├── .dockerignore # files to keep OUT of the image
└── package.json # dependencies & scripts
Keeping the database file in its own data/ folder matters: that folder is the one thing we'll persist outside the container so your users don't vanish every time the app restarts.
package.json
{
"name": "user-api",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.19.2",
"sqlite3": "^5.1.7"
}
}
Node 18+ ships a built-in --watch flag that restarts the process on file changes, so we no longer need a separate tool like nodemon for a simple dev loop.
The Express Application
Express is the kitchen of our food truck: it takes orders (HTTP requests) and coordinates the staff (routes and database) to serve responses. Start with the entry point.
// src/index.js
const express = require('express');
const userRoutes = require('./routes/users');
const app = express();
app.use(express.json()); // parse JSON request bodies
app.use('/api/users', userRoutes); // mount the users router
// A simple health check makes container monitoring easy
app.get('/health', (req, res) => res.json({ status: 'ok' }));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Reading the port from process.env.PORT (with a sensible fallback) is essential for containers — the environment, not hard-coded source, decides the port.
The route handlers
// src/routes/users.js
const express = require('express');
const db = require('../database');
const router = express.Router();
// GET /api/users — list all users
router.get('/', (req, res) => {
db.all('SELECT * FROM users ORDER BY id', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
// POST /api/users — create a user
router.post('/', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
db.run(
'INSERT INTO users (name, email) VALUES (?, ?)',
[name, email],
function (err) {
if (err) return res.status(500).json({ error: err.message });
// "this.lastID" is the id SQLite just assigned
res.status(201).json({ id: this.lastID, name, email });
}
);
});
module.exports = router;
⚠️ Always use parameterized queries
Notice the ? placeholders and the values array. Never build SQL by gluing strings together with user input — that's how SQL-injection attacks get in. Placeholders let the driver safely escape every value.
Setting Up SQLite3
SQLite is a perfect fit for containers because it's serverless — the entire database is a single file, so there's no separate database process to run or network to configure. Think of it as a filing cabinet built into the food truck rather than a warehouse across town.
The schema
# schema.sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
The connection module
// src/database.js
const sqlite3 = require('sqlite3').verbose();
const fs = require('fs');
const path = require('path');
// The data/ folder is mounted as a volume, so the file survives restarts
const dbPath = path.resolve(__dirname, '../data/database.sqlite');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) return console.error('DB open error:', err.message);
console.log('Connected to SQLite at', dbPath);
});
// Run the schema once at startup so a fresh volume gets its table
const schema = fs.readFileSync(path.resolve(__dirname, '../schema.sql'), 'utf8');
db.exec(schema);
module.exports = db;
Reading schema.sql and running it on startup means a brand-new container (or a fresh, empty volume) automatically creates the users table — no manual setup step.
Writing the Dockerfile
The Dockerfile is the recipe that turns your source into an image. Each line is an instruction Docker runs in order, and each becomes a cached layer.
# Dockerfile
# 1. Start from a small, official Node base image (pin the version!)
FROM node:18-alpine
# 2. Set the working directory inside the container
WORKDIR /app
# 3. Copy ONLY the manifest first, then install.
# This layer is cached and re-used unless package.json changes,
# so code edits don't force a slow re-install every build.
COPY package*.json ./
RUN npm install --omit=dev
# 4. Now copy the rest of the source
COPY . .
# 5. Make the data directory and declare it a volume mount point
RUN mkdir -p /app/data
VOLUME /app/data
# 6. Document the port the app listens on
EXPOSE 3000
# 7. The command that runs when the container starts
CMD ["npm", "start"]
Walking through it: FROM chooses the foundation — alpine is a tiny Linux, keeping the image small and the attack surface low. WORKDIR is the kitchen where everything happens. The copy-manifest-then-install-then-copy-code ordering in steps 3-4 is the single most important optimization in the file: it lets Docker reuse the cached npm install layer whenever you've only changed application code.
The .dockerignore file
Just as a .gitignore keeps junk out of Git, a .dockerignore keeps junk out of your image — smaller, faster, safer builds.
# .dockerignore
node_modules
npm-debug.log
data
.git
.env
Dockerfile
docker-compose.yml
Excluding node_modules is important: you want the container to install fresh Linux-compatible dependencies, not copy in whatever was built on your host OS.
Docker Compose
Typing long docker run commands with a dozen flags gets old fast. Docker Compose lets you describe how to build and run everything in one readable YAML file, then start it all with a single command. It's the conductor coordinating the orchestra.
# docker-compose.yml
services:
app:
build: . # build using the Dockerfile in this folder
ports:
- "3000:3000" # host:container — map your machine's 3000 in
volumes:
- ./src:/app/src # live-reload: edits on the host appear in the container
- ./data:/app/data # persist the SQLite file OUTSIDE the container
environment:
- NODE_ENV=development
- PORT=3000
command: npm run dev # override CMD with the watch-mode script
restart: unless-stopped
Two kinds of volumes are doing different jobs here. Mounting ./src is for development convenience — code you edit on your laptop is instantly visible inside the running container. Mounting ./data is for persistence — the database file lives on your host, so stopping and rebuilding the container never erases your users.
⚠️ Without a volume, your data disappears
Containers are ephemeral by design. Anything written inside a container's own filesystem is gone the moment you remove it. The ./data:/app/data volume is what turns a throwaway container into a real, stateful application. Forget it and every restart wipes the database.
💡 The version: key is gone
Older tutorials start the file with version: '3.8'. The modern Compose (v2) specification ignores that key, so we simply leave it out. If you see it in old code, it's harmless but no longer required.
Building & Running
With every file in place, the whole application comes up with one command.
# Build the image and start the container (foreground, logs visible)
docker compose up --build
# Or run it in the background (detached)
docker compose up --build -d
In a second terminal, exercise the API:
# Create a user
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Ada","email":"ada@example.com"}'
# List users
curl http://localhost:3000/api/users
Output
[
{ "id": 1, "name": "Ada", "email": "ada@example.com",
"created_at": "2026-07-31 14:02:11" }
]
Stop the user with Ada still in the database, then bring it all down:
# View running containers and their logs
docker compose ps
docker compose logs -f
# Stop and remove the containers (the ./data volume persists!)
docker compose down
# Bring it back up — Ada is still there, because data/ lived on your host
docker compose up -d
💡 docker compose vs docker-compose
Modern Docker ships Compose as a built-in subcommand — docker compose (a space). The old standalone binary was docker-compose (a hyphen). Both usually work today; prefer the space form in new projects.
Practice & Quiz
🏋️ Exercise 1: Prove persistence
Goal: Demonstrate that the data/ volume actually persists data across a full container teardown.
💡 Hint
Create a user, run docker compose down (which removes the container), bring it back up, and list users again. If the volume works, your user is still there.
✅ Solution
docker compose up -d --build
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Grace","email":"grace@example.com"}'
docker compose down # container destroyed
docker compose up -d # brand new container
curl http://localhost:3000/api/users
# Grace is STILL listed — the SQLite file lived in ./data on the host.
🏋️ Exercise 2: Add a DELETE route
Goal: Add a DELETE /api/users/:id handler that removes one user by id and returns the right status codes.
✅ Solution
// in src/routes/users.js
router.delete('/:id', (req, res) => {
db.run('DELETE FROM users WHERE id = ?', [req.params.id], function (err) {
if (err) return res.status(500).json({ error: err.message });
if (this.changes === 0) {
return res.status(404).json({ error: 'user not found' });
}
res.status(204).send(); // 204 No Content on success
});
});
this.changes tells you how many rows were affected — zero means nothing matched, so return 404.
🎯 Quick Quiz
Question 1: Why copy package.json and run npm install before copying the rest of the source?
Question 2: What happens to data written inside a container that has no volume, after you run docker compose down?
Question 3: In ports: "3000:3000", what does the mapping mean?
Best Practices & Pitfalls
✅ Do
- Pin base image versions (
node:18-alpine, never barenode:latest) - Add a
.dockerignoresonode_modulesand secrets never enter the image - Persist stateful data with a named volume or a host bind mount
- Read configuration from environment variables, not hard-coded values
- Run as a non-root user in production images for security
❌ Don't
- Bake secrets or
.envfiles into the image — pass them at runtime - Copy your host's
node_modulesinto a Linux container - Forget the data volume and then wonder why your database keeps resetting
- Build SQL with string concatenation — always use
?placeholders
✅ Level up: multi-stage builds
For production, a multi-stage build uses one stage to install and build, then copies only the finished artifacts into a tiny final image — like a prep kitchen feeding a lean service kitchen. Smaller images ship faster and expose less.
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
FROM node:18-alpine
WORKDIR /app
COPY --from=build /app ./
CMD ["npm", "start"]
Summary
🎉 Key Takeaways
- A Dockerfile builds an image; an image runs as a container
- Order Dockerfile instructions to maximize layer caching — manifest and install before code
- Volumes are what make containers stateful; without one, data is lost on removal
- Docker Compose turns a whole build-and-run configuration into a single file and command
- SQLite's single-file, serverless design makes it a natural fit for containerized apps
📚 Additional Resources
- Docker — Official Get Started guide
- Docker — Compose documentation
- Express — Getting started
- SQLite — Documentation
🚀 What's Next?
You've containerized a specific app end to end. Next we zoom out for the bigger picture: Docker and Web Development — A Developer's Journey, covering multi-service architectures, MongoDB and Redis, and production deployment patterns.
🎉 Fully containerized!
Your API now runs the same way everywhere. "It works on my machine" is officially retired — because now the machine ships with the code.