Fastify Prisma REST backend
Typescript template for Fastify REST API with Prisma and JWT authentication.

Daniel Gustaw
• 7 min read

Fastify Prisma REST backend
I am creating a lot of Fastify projects and in this article I gathered most common parts of them.
Project setup:
pnpm init
pnpm --package=typescript dlx tsc --init
pnpm add -D typescript @types/node
optionally add
{
"pnpm": {
"neverBuiltDependencies": []
}
}
to package.json
to avoid pnpm
asking for approval for every build.
Env validation
We do not want to allow for serving of project if there are missing env variables. Thanks to zod
we can validate if .env contains all required values.
mkdir -p src && touch src/config.ts
pnpm add zod
add file src/config.ts
import { z } from 'zod';
export const serverVariables = z.object({
DATABASE_URL: z.string(),
JWT_SECRET: z.string(),
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
PORT: z.coerce.number().int().default(4747),
});
export const config = serverVariables.parse(process.env);
Prisma connection
Most of the projects need a database.
pnpm add prisma @prisma/client
npx prisma init
then agree for builds.
pnpm approve-builds
Sync prisma schema if exists
pnpm dlx prisma db pull
touch src/db.ts
Add Fastify
Uncomment "resolveJsonModule": true,
line in tsconfig.json
.
Install packages
pnpm add fastify @fastify/cors @fastify/sensible jsonwebtoken
pnpm add -D @types/jsonwebtoken tsx
Add file src/fastify.ts
import fastify, {
FastifyInstance, FastifyPluginOptions,
FastifyReply,
FastifyRequest, RouteShorthandOptions,
} from 'fastify';
import {config} from "./config";
import cors from '@fastify/cors';
import fastifySensible from '@fastify/sensible';
import {FastifyRouteConfig} from "fastify/types/route";
import jwt from 'jsonwebtoken';
import pJson from '../package.json';
interface TokenPayload {
id: number,
iat: number,
exp: number
}
interface UserProjection {
id: number
}
declare module 'fastify' {
interface FastifyRequest {
user: UserProjection | null;
}
}
export function verifyToken(token: string): UserProjection {
const payload = jwt.verify(token, config.JWT_SECRET) as TokenPayload;
return {
id: payload.id,
}
}
function isProtected(config: FastifyRouteConfig): boolean {
return (
Boolean('isProtected' in config && config.isProtected)
);
}
function getErrorMessage(error: unknown): string {
if(error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
function version(_req: FastifyRequest, res: FastifyReply): void {
res.code(200).send({
name: pJson.name,
version: pJson.version,
});
};
const PROTECTED: RouteShorthandOptions = { config: { isProtected: true } };
const PUBLIC: RouteShorthandOptions = { config: { isProtected: false } };
function router(
server: FastifyInstance,
_options: FastifyPluginOptions,
next: () => void,
) {
server.get('/', PUBLIC, version);
next();
}
export function getFastifyInstance(): FastifyInstance {
const app = fastify({
logger: config.NODE_ENV === 'development',
bodyLimit: 100 * 1048576,
});
app.register(cors, {});
app.register(fastifySensible);
app.addHook(
'onRequest',
async (
request: FastifyRequest<{ Headers: { authorization?: string } }>,
reply: FastifyReply,
) => {
// If the route is not private we ignore this hook
if (isProtected(request.routeOptions.config)) {
const authHeader = request.headers.authorization;
if (typeof authHeader !== 'string') {
reply.unauthorized('No Authorization header');
return;
}
const token: string = String(authHeader)
.replace(/^Bearer\s+/, '')
.trim();
if (!token) {
reply.unauthorized('Token is empty');
return;
}
try {
request.user = verifyToken(token);
} catch (error) {
return reply.unauthorized(getErrorMessage(error));
}
}
},
);
app.register(router);
return app;
}
This is minimalistic setup with users validation. We can import it in src/index.ts
import {getFastifyInstance} from './fastify';
import { config } from './config';
const app = getFastifyInstance();
app.listen(
{ port: config.PORT, host: '0.0.0.0' },
(err: Error | null, host: string) => {
if (err) {
throw err;
}
console.info(`server listening on ${host}`);
},
);
add script dev
to package.json
{
"scripts": {
"dev": "infisical run --env=dev --path=/apps/fastify -- tsx watch ./src/index.ts",
}
}
Test by
pnpm dev
and
http localhost:4747
Building
pnpm add -D tsup
add scripts
{
"scripts": {
"build": "tsup src/index.ts",
"serve:prod": "infisical run --domain https://infisical.preciselab.io --projectId acb5ccfb-c211-4461-a06d-8caa248beea1 --env=prod --path=/apps/fastify -- node dist/index.js"
}
}
E2E test
pnpm add -D vitest
add file test/version.e2e.spec.ts
import {it, expect} from "vitest";
import {getFastifyInstance} from "../src/fastify";
it('should return version', async () => {
const app = getFastifyInstance();
const response = await app.inject({
method: 'GET',
url: '/',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
name: 'fastify',
version: '1.0.0',
});
})
and script
{
"scripts": {
"test": "infisical run --env=dev --path=/apps/fastify -- vitest run",
}
}
call
pnpm test
Test protected route
create route
mkdir -p src/controller/auth
touch src/controller/auth/me.ts
add file src/controller/auth/me.ts
import {FastifyRequest} from "fastify";
import {UserProjection} from "../../types/auth";
export function me(req: FastifyRequest): UserProjection | null {
return req.user;
}
where src/types/auth.ts
contains types excluded from fastify.ts
export interface TokenPayload {
id: number,
iat: number,
exp: number
}
export interface UserProjection {
id: number
}
We can also exclude router.ts
from fastify.ts
to separate file
import {FastifyInstance, FastifyPluginOptions, RouteShorthandOptions} from "fastify";
import {version} from "./controllers/app/version";
import {me} from "./controllers/auth/me";
const PROTECTED: RouteShorthandOptions = { config: { isProtected: true } };
const PUBLIC: RouteShorthandOptions = { config: { isProtected: false } };
export function router(
server: FastifyInstance,
_options: FastifyPluginOptions,
next: () => void,
) {
server.get('/', PUBLIC, version);
server.get('/me', PROTECTED, me);
next();
}
and move version
to controllers/app/version.ts
import {FastifyReply, FastifyRequest} from "fastify";
import pJson from "../../../package.json";
export function version(_req: FastifyRequest, res: FastifyReply): void {
res.code(200).send({
name: pJson.name,
version: pJson.version,
});
}
Now we can test it in file test/auth.e2e.spec.ts
import {it, expect} from "vitest";
import {getFastifyInstance} from "../src/fastify";
import jwt from "jsonwebtoken";
import {config} from "../src/config";
it('should return me', async () => {
const app = getFastifyInstance();
const token = jwt.sign({
"id": 6,
"iat": 1739986214,
"exp": (Date.now() / 1000) + 3600 // 1 hour from now
}, config.JWT_SECRET);
const response = await app.inject({
method: 'GET',
url: '/me',
headers: {
authorization: `Bearer ${token}`
}
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
id: 6,
});
})
Deployment
Dockerfile
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare [email protected] --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
FROM base AS build
COPY . .
RUN pnpm dlx prisma generate
RUN pnpm build
RUN apk add --no-cache bash curl && curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \
&& apk add infisical
CMD pnpm serve:prod
Docker compose: docker-compose.yml
services:
app:
image: registry.digitalocean.com/main/up-fastify
ports:
- '4747:4747'
environment:
- INFISICAL_TOKEN
Hosts file
[local]
127.0.0.1 env=prod ansible_python_interpreter=/usr/bin/python3
[api]
142.132.182.19 ansible_user=root env=prod ansible_python_interpreter=/usr/bin/python3
Ansible playbook deploy.yml
---
- name: Build Backend
hosts: local
connection: local
vars:
image_url: registry.digitalocean.com/main/api-domain-com
tasks:
- name: Build Image
ansible.builtin.shell: >
DOCKER_BUILDKIT=1 docker build -t {{image_url}} .
- name: Push Image
ansible.builtin.shell: >
docker push {{image_url}}
- name: Deploy Backend
hosts: api
vars:
path: /root/api.domain.com
tasks:
- name: Creates Api directory
file:
path: '{{ path }}'
state: directory
- name: Copy Docker Compose
copy:
src: ./docker-compose.yml
dest: '{{ path }}/docker-compose.yml'
- name: Pull Image
shell:
cmd: docker compose pull
chdir: '{{ path }}'
- name: Restart Image
shell:
cmd: docker compose up -d --remove-orphans
chdir: '{{ path }}'
Go to infisical access control, select service token and create new with read
permissions selecting scope /**
and expiration time never
.
Finally add deploy command to Makefile
deploy:
ansible-playbook -i "hosts.${ENV}" deploy.yml
Then having hosts.prod
and hosts.stag
you can deploy by running ENV=prod make deploy
or ENV=stag make deploy
.
To tie deployment with domain on server add to /etc/caddy/Caddyfile
lines
api.domain.com {
reverse_proxy localhost:4747
}
and reload by
systemctl reload caddy
Other articles
You can find interesting also.
![Maximum Inequality [Linear Search] rust and typescript](/_astro/3e9e4456-ce05-4bd0-b136-7bbd4c952edd_1KLSIl.png)
Maximum Inequality [Linear Search] rust and typescript
Simple hackeartch task solved in node js and rust. You con compare these two languages on example of this problem. I recommend to solve it independently before reading solutions.

Daniel Gustaw
• 6 min read

The impact of indexing on search performance in MySQL database
Using indexes speeds up searches and increases table size while slowing down modifications. The article shows how to profile queries and measure the impact of indexes on search performance.

Daniel Gustaw
• 15 min read

From MLP to CNN. Neural Networks for MNIST Digit Recognition
We build and compare four neural network architectures in PyTorch, visualize performance, explore complexity vs. accuracy, and show why CNNs excel at image classification.

Daniel Gustaw
• 13 min read