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

Daniel Gustaw
• 7 min read

Fastify Prisma REST backend
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 start project if there are missing env variables.
```bash
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
pnpm add prisma @prisma/client
npx prisma init
pnpm approve-builds
then agree for 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.

Retry Policy - How to Handle Random, Unpredictable Errors
Learn how to make random, unreproducible errors no longer a threat to your program.

Daniel Gustaw
• 6 min read

CodinGame: Quaternion Multiplication - Rust, NodeJS - Parsing, Algebra
In this article, we will see how to implement the multiplication of quaternions in Rust and NodeJS. You will learn about parsing and algebra.

Daniel Gustaw
• 17 min read

How to configure SSL in local development
Setting up an https connection on the localhost domain can be challenging if you're doing it for the first time. This post is a very detailed tutorial with all the commands and screenshots.

Daniel Gustaw
• 12 min read