By the end of this tutorial, you'll have Postgres running locally in Docker, a versioned schema with migrations, and three working endpoints: shorten, redirect, delete.
← Back to Module 02 overviewPOST /shorten returns 201 with a code.GET /:code 302-redirects to the target.DELETE /:code returns 204.Create docker-compose.yml:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: shortener
POSTGRES_PASSWORD: dev
POSTGRES_DB: shortener
ports: ["5432:5432"]
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shortener"]
interval: 3s
timeout: 3s
retries: 10
volumes:
pgdata:
docker compose up -d
docker compose ps shows the db container as healthy.Create .env:
DATABASE_URL=postgres://shortener:dev@localhost:5432/shortener PORT=3000 BASE_URL=http://localhost:3000
And .env.example with the same keys but no real values — commit this one.
npm install dotenv pg npm install -D @types/pg
npm install -D node-pg-migrate npm pkg set scripts.migrate="node-pg-migrate -m migrations -j ts" npm pkg set scripts.migrate:up="npm run migrate up" npm pkg set scripts.migrate:down="npm run migrate down"
npm run migrate -- create init
Open the generated file under migrations/ and replace its body with:
import { MigrationBuilder } from 'node-pg-migrate';
export const up = (pgm: MigrationBuilder) => {
pgm.createTable('links', {
id: { type: 'bigserial', primaryKey: true },
code: { type: 'text', notNull: true, unique: true },
target_url: { type: 'text', notNull: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }
});
pgm.addConstraint('links', 'chk_links_url',
{ check: "target_url ~* '^https?://'" });
};
export const down = (pgm: MigrationBuilder) => pgm.dropTable('links');
Run it:
node --env-file=.env --loader tsx node_modules/node-pg-migrate/bin/node-pg-migrate.cjs up -m migrations -j ts
(Or just put DATABASE_URL in your shell and run npm run migrate:up.)
docker exec -it $(docker compose ps -q db) psql -U shortener -c '\d links' shows the table.Create src/db.ts:
import 'dotenv/config';
import pg from 'pg';
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
Create src/code.ts:
const ALPHABET =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function randomCode(length = 7): string {
let out = '';
for (let i = 0; i < length; i++) {
out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
}
return out;
}
Math.random is fine for v1, but in Module 09 we'll swap it for crypto.randomInt for unguessability.Replace src/server.ts:
import 'dotenv/config';
import express from 'express';
import { pool } from './db.js';
import { randomCode } from './code.js';
const app = express();
const port = Number(process.env.PORT ?? 3000);
const base = process.env.BASE_URL ?? `http://localhost:${port}`;
app.use(express.json());
app.get('/health', (_req, res) => res.json({ ok: true }));
app.post('/shorten', async (req, res) => {
const { url } = req.body ?? {};
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'url must be http(s)://...' });
}
if (url.length > 2048) {
return res.status(400).json({ error: 'url too long' });
}
// Try a few times in case of collision (very unlikely at 7 chars).
for (let i = 0; i < 5; i++) {
const code = randomCode();
try {
await pool.query(
'INSERT INTO links(code, target_url) VALUES ($1, $2)',
[code, url]
);
return res.status(201).json({ code, short: `${base}/${code}` });
} catch (e: unknown) {
const err = e as { code?: string };
if (err.code !== '23505') throw e; // 23505 = unique violation
}
}
res.status(500).json({ error: 'could not allocate code' });
});
app.get('/:code', async (req, res) => {
const { rows } = await pool.query(
'SELECT target_url FROM links WHERE code = $1',
[req.params.code]
);
if (rows.length === 0) return res.sendStatus(404);
res.redirect(302, rows[0].target_url);
});
app.delete('/:code', async (req, res) => {
const result = await pool.query(
'DELETE FROM links WHERE code = $1',
[req.params.code]
);
res.sendStatus(result.rowCount === 0 ? 404 : 204);
});
app.listen(port, () => console.log(`listening on :${port}`));
npm run dev
In a second terminal:
# Create a short link
curl -s -X POST http://localhost:3000/shorten \
-H 'content-type: application/json' \
-d '{"url":"https://example.com/some/long/path"}'
# → {"code":"aB3xY12","short":"http://localhost:3000/aB3xY12"}
# Follow the redirect (-i shows headers, -L follows)
curl -i http://localhost:3000/aB3xY12
# → HTTP/1.1 302 Found
# → Location: https://example.com/some/long/path
# Bad input
curl -s -X POST http://localhost:3000/shorten \
-H 'content-type: application/json' \
-d '{"url":"javascript:alert(1)"}'
# → {"error":"url must be http(s)://..."}
# Delete
curl -i -X DELETE http://localhost:3000/aB3xY12
# → HTTP/1.1 204 No Content
git checkout -b module-02 git add . git commit -m "module 02: links table, shorten/redirect/delete endpoints" git push -u origin module-02
Open a PR. Self-merge for now (CI gates land in Module 08).
docker compose up -d.dotenv.localhost autocomplete oddly.app.use(express.json()) appears before the route handlers.It works on the happy path. Now we make sure it stays working — tests, including a TDD pass on the encoder.