Introduzione
Il codice e’ disponibile sulla mia repository.
Per sviluppi personalizzati o consulenza inviate una email a [email protected]
Dopo aver visto come impostare l’ambiente AWS e l’ambiente in locale, iniziamo a creare tutti i componenti necessari per il nostro applicativo.
Usero’ per il frontend SvelteKit, mentre per il backend Fastify. Le librerie installate sono Drizzle ORM per modellare e interrogare il database e Typebox per definire e validare i dati.
Il frontend vivra’ su Lambda con davanti una CDN, Cloudfront, mentre Fastify sara’ su un container Fargate su ECS, dato che voglio usare un database relazionale.
In generale non e’ una buona idea aprire e chiudere connessioni al database continuamente, quindi non usero’ Lambda per l’API.
E’ molto probabile che dovrete chiedere al supporto AWS i permessi per acquistare il dominio e per creare distribuzioni Cloudfront, bastera’ aprire un ticket sul supporto.
Creazione account Production e registrazione dominio
Prima di iniziare bisogna creare l’account di produzione, Production, andiamo quindi sul nostro account root e seguiamo i passaggi dei post precedenti.
Usciamo dall’account root e impostiamo i permessi da IAM Identity Center tramite Administration.
Entriamo quindi su Production e andiamo su Route53, dove potremo acquistare il dominio. Nel caso fossimo gia’ in possesso di un dominio registrato su un provider differente, dovremo alterare i record NS per puntare su AWS.
Acquistato il dominio avremo una Hosted Zone gia’ pronta:
Nel caso si avesse il dominio creato esternamente esterno, ora possiamo copiare i record NS e impostarli sul proprio provider.
Torniamo sull’account Development e creiamo un’altra Hosted Zone chiamata dev.roberttimisapp.com.
A questo punto copiamo i record NS di dev e li andremo a inserire sulla Hosted Zone prod, mettendo Record type NS e TTL 172800:
Configurazione infrastruttura
Ci serviranno diversi elementi, per fortuna molte risorse vengono create in automatico da SST. Dovremo creare uno stack per gestire i domini, una VPC, un RDS Postgresql, un Service per l’API, un SvelteKitSite per il frontend e uno stack apposito per eseguire le migrazioni quando facciamo deploy.
DNS
Iniziamo creando uno stack chiamato DNS fatto cosi’:
// stacks/DNS.ts
import { HostedZone } from "aws-cdk-lib/aws-route53";
import { StackContext } from "sst/constructs";
const zoneMap: Record<string, string> = {
dev: "dev.roberttimisapp.com",
prod: "roberttimisapp.com",
};
export function DNS({ stack }: StackContext) {
const zone = zoneMap[stack.stage] || zoneMap.dev;
const domain = zoneMap[stack.stage] || `${stack.stage}.${zoneMap.dev}`;
const hostedZone = HostedZone.fromLookup(stack, "HostedZone", {
domainName: zone,
});
return { zone, domain, hostedZone };
}
Questo stack lo useremo per avere un punto di riferimento centrale dei domini che utilizziamo.
VPC
La VPC, che sta per “Virtual Private Network” ci permette di creare e di partizionare una rete privata in sottoreti, di cui avremo tre tipologie principali.
Public
I servizi all’interno hanno un IP pubblico e possono sia connettersi all’esterno, sia essere raggiunti da internet. Il traffico in ingresso va direttamente alle macchine dato che posseggono un indirizzo IP pubblico, mentre quello in uscita passa da un componente chiamato Internet Gateway.
All’interno ci sara’ il nostro Application Load Balancer.
Private
I servizi non hanno IP pubblico e si collegano solamente all’esterno. Il traffico non puo’ entrare, dato che manca un indirizzo IP pubblico, se non tramite qualche rotta definita da noi.
Per far arrivare traffico verra’ messa una rotta che va dal nostro Load Balancer ai container, che staranno all’interno di questa sottorete.
Per uscire invece il traffico deve passare attraverso un NAT Gateway, che andremo a sostituire con una versione custom molto meno costosa rispetto a quella offerta da AWS.
Isolated
I servizi non hanno IP pubblico e non possono collegarsi ne’ all’interno ne all’esterno.
Qui vivra’ il nostro database.
Installiamo il provider per il NAT Gateway nella root del progetto:
pnpm i cdk-fck-nat -w
E andiamo a creare uno stack chiamato Networking:
// stacks/Networking.ts
import { FckNatInstanceProvider } from "cdk-fck-nat";
import { StackContext } from "sst/constructs";
import {
InstanceClass,
InstanceSize,
InstanceType,
Peer,
Port,
SubnetType,
Vpc,
} from "aws-cdk-lib/aws-ec2";
export function Networking({ stack }: StackContext) {
const natGatewayProvider = new FckNatInstanceProvider({
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.NANO),
});
// La sottorete standard ha come indirizzo 10.0.0.0/16
const vpc = new Vpc(stack, "Vpc", {
subnetConfiguration: [
{ cidrMask: 24, name: "Public", subnetType: SubnetType.PUBLIC },
{
cidrMask: 24,
name: "Private",
subnetType: SubnetType.PRIVATE_WITH_EGRESS,
},
{
cidrMask: 24,
name: "Isolated",
subnetType: SubnetType.PRIVATE_ISOLATED,
},
],
natGatewayProvider,
maxAzs: 2,
});
// Permettiamo alla sottorete privata di avere connessioni in uscita
// tramite il NAT Gateway
vpc.privateSubnets.forEach((privateSubnet) => {
natGatewayProvider.connections.allowFrom(
Peer.ipv4(privateSubnet.ipv4CidrBlock),
Port.tcp(443),
);
});
return {
vpc,
};
}
RDS
Sta per Relational Database Service, lo useremo per creare una istanza Postgres gestita.
Creiamo uno stack Database:
// stacks/Database.ts
import { InstanceClass, InstanceSize, InstanceType } from "aws-cdk-lib/aws-ec2";
import {
DatabaseInstance,
DatabaseInstanceEngine,
PostgresEngineVersion,
} from "aws-cdk-lib/aws-rds";
import { StackContext, use } from "sst/constructs";
import { Networking } from "./Networking";
// La grandezza della macchina e lo storage allocato, in GiB, vanno scelti in base alle necessita'
const sizeMap: Record<string, any> = {
dev: {
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MICRO),
allocatedStorage: 10,
maxAllocatedStorage: 20,
},
prod: {
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM),
allocatedStorage: 10,
maxAllocatedStorage: 100,
},
};
export function Database({ stack }: StackContext) {
const { vpc } = use(Networking);
const size = sizeMap[stack.stage] || sizeMap.dev;
const db = new DatabaseInstance(stack, "Database", {
vpc,
vpcSubnets: { subnets: vpc.isolatedSubnets },
engine: DatabaseInstanceEngine.postgres({
version: PostgresEngineVersion.VER_14,
}),
databaseName: "app",
...size,
});
return { db };
}
Package core
Prima di andare avanti impostiamo il package core, un pacchetto che useremo per condividere codice tra api, frontend e funzioni.
Cancelliamo tutto cio’ che non abbiamo fatto noi e installiamo le dipendenze che useremo:
cd packages/core
rm -rf src/*
pnpm i -D drizzle-kit
pnpm i postgres drizzle-orm drizzle-typebox @sinclair/typebox
Andiamo a configurare drizzle tramite un file, drizzle.config.ts:
// packages/core/drizzle.config.ts
import type { Config } from "drizzle-kit";
export default {
schema: "./src/**/schema.ts",
out: "./drizzle",
driver: "pg",
dbCredentials: {
connectionString: "postgres://postgres@localhost/app",
},
} satisfies Config;
Con questa configurazione potremo generare le migrazioni ed applicarle in locale tramite due comandi, che aggiungiamo a package.json:
...
"scripts": {
...
"generate": "drizzle-kit generate:pg",
"migrate": "drizzle-kit push:pg"
},
...
Definiamo la nostra prima entita’, per esempio users:
// packages/core/src/users/schema.ts
export * as UsersSchema from "./schema";
import { timestamp, varchar, pgTable, bigserial } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-typebox";
export const table = pgTable("users", {
id: bigserial("id", { mode: "number" }).primaryKey(),
name: varchar("name").notNull(),
email: varchar("email").notNull().unique(),
password: varchar("password"),
role: varchar("role", { enum: ["admin", "customer"] }).notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
});
export const insert = createInsertSchema(table);
export const select = createSelectSchema(table);
export type Select = typeof table.$inferSelect;
export type Insert = typeof table.$inferInsert;
// packages/core/src/users/model.ts
export * as UsersModel from "./model";
import { db } from "../client";
import { table, Insert } from "./schema";
export async function list() {
return await db.query.users.findMany({});
}
export async function create(user: Insert) {
const result = await db.insert(table).values(user).returning();
return result.at(0);
}
Per creare le migrations dobbiamo eseguire i seguenti comandi mentre il database e’ acceso, all’interno di packages/core:
pnpm generate && pnpm migrate
Ora creiamo due file, client.ts per il client che useremo per collegarci al database e migrator.ts che useremo dopo per fare le migrazioni durante i deploy:
// packages/core/src/client.ts
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { UsersSchema } from "./users/schema";
const connectionString =
process.env.NODE_ENV !== "production"
? "postgres://postgres@localhost/app"
: `postgres://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}/${process.env.DB_NAME}`;
const sql = postgres(connectionString);
export const db = drizzle(sql, { schema: { users: UsersSchema.table } });
// packages/core/src/migrator.ts
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
export async function up(connectionString: string) {
const sql = postgres(connectionString, { max: 1 });
const db = drizzle(sql);
// Il path e' corretto, ma vedremo dopo perche'
await migrate(db, { migrationsFolder: "./drizzle" });
await sql.end();
}
Fatto cio’ andiamo avanti con la creazione della Lambda per la migrazione.
Lambda migrator
Per avere il database allineato con quello che facciamo in locale, creiamo un costrutto chiamato Script che verra’ eseguito ogni volta che facciamo un deploy.
Per prima cosa puliamo la cartella functions:
cd packages/functions
rm -rf src/*
Definiamo la nostra funzione in migrator.ts:
import { up } from "@app/core/migrator";
export const handler = async () => {
// TS e node 18 non vanno molto d'accordo su fetch
// @ts-ignore
const resp = await fetch(
`http://localhost:2773/secretsmanager/get?secretId=${process.env.SECRET_ARN}`,
{
method: "GET",
headers: {
"X-Aws-Parameters-Secrets-Token": process.env.AWS_SESSION_TOKEN,
},
},
);
const secret = await resp.json();
const { host, dbname, username, password } = JSON.parse(secret.SecretString);
await up(`postgres://${username}:${password}@${host}/${dbname}`);
};
Questa funzione avra’ il semplice compito di recuperare le credenziali del nostro database e di applicare le migration che avremo creato in locale.
Lo stack che la accompagna e’ il seguente:
// stacks/Migrator.ts
import { Script, StackContext, use } from "sst/constructs";
import { Database } from "./Database";
import { Networking } from "./Networking";
import {
ParamsAndSecretsLayerVersion,
ParamsAndSecretsVersions,
} from "aws-cdk-lib/aws-lambda";
import { Port } from "aws-cdk-lib/aws-ec2";
export function Migrator({ stack }: StackContext) {
const { vpc } = use(Networking);
const { db } = use(Database);
const script = new Script(stack, "Migrator", {
onCreate: "./packages/functions/src/migrator.handler",
onUpdate: "./packages/functions/src/migrator.handler",
defaults: {
function: {
// Ci attacchiamo alla VPC per comunicare con il database
vpc,
vpcSubnets: { subnets: vpc.privateSubnets },
// Usiamo un layer particolare per poter recuperare i secret comodamente
paramsAndSecrets: ParamsAndSecretsLayerVersion.fromVersion(
ParamsAndSecretsVersions.V1_0_103,
),
// Aggiungiamo i file necessari per la migrazione.
copyFiles: [
{
from: "./packages/core/drizzle",
// Questo e' il motivo del path precedente
to: "./drizzle",
},
],
environment: {
// Specifichiamo l'ARN (identificativo) del segreto contenente le credenziali
SECRET_ARN: db.secret!.secretArn,
},
},
},
});
// Permettiamo alle funzioni di collegarsi al database
script.createFunction!.connections.allowTo(db, Port.tcp(5432));
script.updateFunction!.connections.allowTo(db, Port.tcp(5432));
// Permettiamo alle funzioni di leggere il segreto
db.secret!.grantRead(script.createFunction!);
db.secret!.grantRead(script.updateFunction!);
return { script };
}
ECR, ECS e Fargate
Per il nostro backend useremo ECS, che sta per Elastic Container Service. L’immagine che costruiremo sara’ caricata su ECR, l’Elastic Container Registry.
ECS supporta anche container eseguiti su EC2, macchine virtuali, ma noi useremo Fargate dove possiamo semplicemente specificare l’immagine e qualche parametro, il resto viene gestito.
Usando SST gran parte di tutto cio’ e’ creato e gestito dal framework, a noi basta dare qualche impostazione.
Creiamo la cartella api dentro packages, copiamo i file package.json, sst-env.d.ts e tsconfig.json da functions e installiamo le dipendenze necessarie:
mkdir -p packages/api/src
cp packages/core/{package.json,sst-env.d.ts,tsconfig.json} packages/api/
pnpm i fastify @fastify/type-provider-typebox
Modifichiamo package.json e aggiungiamo qualche script:
{
"name": "@app/api",
...
"scripts": {
"dev": "sst bind tsx src/main.ts",
"build": "node build.mjs",
...
},
...
Creiamo build.mjs per impacchettare il tutto:
// packages/api/build.mjs
import * as esbuild from "esbuild";
await esbuild.build({
entryPoints: ["src/main.ts"],
bundle: true,
outfile: ".build/main.mjs",
format: "esm",
target: "esnext",
platform: "node",
// Questo serve per mantenerci compatibili con commonJS, purtroppo
banner: {
js: `
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire as topLevelCreateRequire } from 'module';
const require = topLevelCreateRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
`,
},
});
Un Dockerfile per costruire l’immagine:
# packages/api/Dockerfile
FROM node:18-bullseye-slim as builder
WORKDIR /usr/src/app
RUN npm install -g pnpm
COPY package.json ./
COPY pnpm-*.yaml ./
COPY packages/api/package.json ./packages/api/package.json
COPY packages/core/package.json ./packages/core/package.json
RUN pnpm install
COPY . .
WORKDIR /usr/src/app/packages/api
RUN pnpm build
FROM node:18-bullseye-slim
COPY --from=builder --chown=nobody:root /usr/src/app/packages/api/.build ./
USER nobody
CMD [ "node", "main.mjs" ]
E una bozza di API:
// packages/api/src/main.ts
import Fastify from "fastify";
import {
TypeBoxTypeProvider,
TypeBoxValidatorCompiler,
} from "@fastify/type-provider-typebox";
import { UsersModel } from "@app/core/users/model";
import { UsersSchema } from "@app/core/users/schema";
const fastify = Fastify({
logger: true,
})
// Con questo sfruttiamo i tipi generati da drizzle per validare gli input
.withTypeProvider<TypeBoxTypeProvider>()
.setValidatorCompiler(TypeBoxValidatorCompiler);
fastify.get("/users", async (_request, reply) => {
reply.send(await UsersModel.list());
});
fastify.post(
"/user",
// Come ad esempio qui
{ schema: { body: UsersSchema.insert } },
async (request, reply) => {
const result = await UsersModel.create(request.body);
if (result) {
reply.status(200).send(result.id);
} else {
reply.status(500).send();
}
},
);
// Necessario per il nostro Load Balancer
fastify.get("/health", (_request, reply) => {
reply.status(200).send("Ok");
});
fastify.listen(
{
port: 3000,
// Ascoltiamo su tutte le interfacce nel container
host: process.env.NODE_ENV === "production" ? "0.0.0.0" : "localhost",
},
(err, _address) => {
if (err) {
fastify.log.error(err);
process.exit(1);
}
},
);
Ora che abbiamo la nostra bozza di API possiamo costruire lo stack:
// stacks/API.ts
import { Config, StackContext, dependsOn, use } from "sst/constructs";
import { Service } from "sst/constructs";
import { LinuxParameters } from "aws-cdk-lib/aws-ecs";
import { Port, SecurityGroup } from "aws-cdk-lib/aws-ec2";
import { Secret as ECSSecret } from "aws-cdk-lib/aws-ecs";
import { DNS } from "./DNS";
import { Networking } from "./Networking";
import { Database } from "./Database";
import { Migrator } from "./Migrator";
// La grandezza della macchina va scelta in base alle necessita'
const sizeMap: Record<string, any> = {
dev: { cpu: "0.25 vCPU", memory: "0.5 GB" },
prod: { cpu: "1 vCPU", memory: "2 GB" },
};
export function API({ stack, app }: StackContext) {
// Evitiamo di fare il deploy di risorse che da locale non usiamo
if (app.local) {
const url = new Config.Parameter(stack, "API_URL", {
value: "http://localhost:3000",
});
return { url };
}
const { domain, zone } = use(DNS);
const { db } = use(Database);
const { vpc } = use(Networking);
// Aspettiamo che le migrazioni siano state eseguite
dependsOn(Migrator);
const sg = new SecurityGroup(stack, "ServiceSecurityGroup", {
vpc,
});
// Permettiamoci di collegarci al database
sg.connections.allowTo(db, Port.tcp(5432));
const size = sizeMap[stack.stage] || sizeMap.dev;
const api = new Service(stack, "API", {
file: "./packages/api/Dockerfile",
customDomain: {
domainName: `api.${domain}`,
hostedZone: zone,
},
cdk: {
vpc,
applicationLoadBalancerTargetGroup: {
healthCheck: { path: "/health" },
},
fargateService: {
securityGroups: [sg],
},
container: {
environment: {
NODE_ENV: "production",
},
// Recuperiamo le credenziali direttamente da secret manager
secrets: {
DB_HOST: ECSSecret.fromSecretsManager(db.secret!, "host"),
DB_NAME: ECSSecret.fromSecretsManager(db.secret!, "dbname"),
DB_USER: ECSSecret.fromSecretsManager(db.secret!, "username"),
DB_PASSWORD: ECSSecret.fromSecretsManager(db.secret!, "password"),
},
// Node non e' felice di esser eseguito come pid 1
linuxParameters: new LinuxParameters(stack, "LinuxParameters", {
initProcessEnabled: true,
}),
},
},
...size,
});
const url = new Config.Parameter(stack, "API_URL", {
value: api.customDomainUrl ?? api.url ?? "",
});
return { url };
}
Lambda frontend
Andiamo ora a creare un progetto SvelteKit dentro packages:
cd packages
pnpm create svelte web
...
create-svelte version 5.0.6
┌ Welcome to SvelteKit!
│
◇ Which Svelte app template?
│ Skeleton project
│
◇ Add type checking with TypeScript?
│ Yes, using TypeScript syntax
│
◆ Select additional options (use arrow keys/space bar)
│ ◼ Add ESLint for code linting
│ ◼ Add Prettier for code formatting
│ ◼ Add Playwright for browser testing
│ ◼ Add Vitest for unit testing
└
...
cd web
sed -i 's/vite dev/sst bind vite dev/' package.json
sed -i 's/"name":.*$/"name": "@app\/web",/' package.json
pnpx svelte-add@latest tailwindcss
pnpm i svelte-kit-sst
Modifichiamo svelte.config.js per usare l’adapter fornito da SST:
// packages/web/svelte.config.js
import adapter from "svelte-kit-sst";
import { vitePreprocess } from "@sveltejs/kit/vite";
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [vitePreprocess({})],
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
},
};
export default config;
Aggiungiamo in src sst-env.d.ts per avere i tipi corretti di SST:
printf "/// <reference path=\"../../../.sst/types/index.ts\" />" > src/sst-env.d.ts
Infine creiamo uno stack chiamato Web:
// stacks/Web.ts
import { StackContext, SvelteKitSite, use } from "sst/constructs";
import { DNS } from "./DNS";
import { API } from "./API";
export function Web({ stack }: StackContext) {
const { domain, zone } = use(DNS);
const { url } = use(API);
const web = new SvelteKitSite(stack, "Web", {
path: "./packages/web",
customDomain: {
domainName: domain,
hostedZone: zone,
},
bind: [url],
});
return { web };
}
Il bind di url ci serve per fare riferimento all’url dell’API in uso, possiamo usarla cosi’ ad esempio:
// packages/web/routes/+page.server.ts
import { Config } from "sst/node/config";
import type { UsersSchema } from "@app/core/users/schema";
export async function load() {
const result = await fetch(Config.API_URL + "/users");
const users: Promise<Array<UsersSchema.Select>> = result.json();
return { users };
}
Configurazione SST
Aggiungiamo tutti i nostri stack a sst.config.ts:
// sst.config.ts
import { SSTConfig } from "sst";
import { DNS } from "./stacks/DNS";
import { Networking } from "./stacks/Networking";
import { Database } from "./stacks/Database";
import { API } from "./stacks/API";
import { Web } from "./stacks/Web";
import { Migrator } from "./stacks/Migrator";
export default {
config(input) {
const profiles: Record<string, string> = {
dev: "Development",
prod: "Production",
};
return {
name: "app",
// Modificare qui la regione
region: "eu-central-1",
// Aggiungiamo il profilo
profile: profiles[input.stage || "dev"] || profiles.dev,
};
},
stacks(app) {
// Impostazioni di default per le lambda
app.setDefaultFunctionProps({
runtime: "nodejs18.x",
architecture: "arm_64",
});
// Facciamo in modo che alcune risorse tipo S3 e tabelle Dynamo
// vengano rimosse in automatico su Development
if (app.stage !== "prod") {
app.setDefaultRemovalPolicy("destroy");
}
// Evitiamo il deploy di cose che non servono in locale
if (app.local) {
app.stack(DNS).stack(API).stack(Web);
} else {
app
.stack(DNS)
.stack(Networking)
.stack(Database)
.stack(Migrator)
.stack(API)
.stack(Web);
}
},
} satisfies SSTConfig;
Ora abbiamo tutto il necessario per fare il nostro primo deploy in dev, per farlo ci basta fare:
aws sso login --profile Development
pnpm run deploy --stage dev
Oppure per prod:
aws sso login --profile Production
pnpm run deploy --stage prod
Andate a prepararvi pure un caffe’ on un te’, perche’ ci vorranno circa 10 minuti, dopodiche’ avremo a disposizione il nostro ambiente di sviluppo!
Conclusione
Siamo arrivati alla fine!
Ora abbiamo una base solida per iniziare a creare quello che vogliamo.
Ci sono altre cose che potremmo aggiungere o modificare, come mettere il database in multiAZ se in produzione, applicare il Web Application Firewall sui nostri Cloudfront e altro ancora, ma per tenere il post a una lunghezza accettabile ho preferito tralasciare al momento.
Per il lancio in locale ora abbiamo due opzioni, la prima e’ lanciare postgres tramite docker/podman e poi lanciare il resto:
pnpm dev &
pnpm --filter '@app/api' dev &
pnpm --filter '@app/web' dev &
Oppure potete sfruttare un file presente sulla repository che utilizza Nix. Tramite questo file definiamo i comandi da lanciare, come migrate per generare ed eseguire le migrazioni o local:start che avvia tutto.
Anche le dipendenze sono definite, quindi avremo gia’ a disposizione pnpm, postgres, node18, awscli2 senza doverli installare.
Per installare Nix seguite la guida qui
Una volta terminata l’installazione possiamo usare nix develop per entrare nell’ambiente.
(Usando direnv si apre in automatco, cercando su internet si trova una guida per installarlo)