├── .DS_Store ├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── deploy-on-main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── README.md ├── bun.lockb ├── components.json ├── config └── deploy.yml ├── drizzle.config.ts ├── drizzle ├── 0000_smart_stark_industries.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── next-env.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── .DS_Store ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico └── loaderio-2de975c23c1a762501e85dc19b0ceabd.txt ├── src ├── app │ ├── actions │ │ └── db-test.ts │ ├── api │ │ └── vm │ │ │ ├── device │ │ │ └── route.ts │ │ │ └── usage │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── up │ │ └── route.ts ├── components │ ├── tests │ │ ├── capacity.tsx │ │ ├── chart.tsx │ │ ├── db.tsx │ │ └── images.tsx │ └── ui │ │ ├── button-loading.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── spinner.tsx │ │ └── test-card.tsx ├── lib │ └── utils.ts ├── models │ ├── schema.ts │ └── types.ts └── utils │ └── db.ts ├── tailwind.config.js ├── tailwind.config.ts └── tsconfig.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/.DS_Store -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm - debug.log 5 | README.md 6 | .next 7 | .git 8 | .gitignore -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = tab 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /.github/workflows/deploy-on-main.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | Deploy: 10 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 11 | runs-on: ubuntu-latest 12 | 13 | env: 14 | DOCKER_BUILDKIT: 1 15 | KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} 16 | KAMAL_REGISTRY_USERNAME: ${{ secrets.KAMAL_REGISTRY_USERNAME }} 17 | VPS_IP: ${{ secrets.VPS_IP }} 18 | # Use the same ssh-agent socket value across all jobs 19 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up Ruby 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: 3.2.2 29 | bundler-cache: true 30 | 31 | - name: Install dependencies 32 | run: | 33 | gem install specific_install 34 | gem specific_install https://github.com/basecamp/kamal.git 35 | 36 | - name: Setup SSH with a passphrase 37 | env: 38 | SSH_PASSPHRASE: ${{secrets.SSH_PASSPHRASE}} 39 | SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} 40 | run: | 41 | ssh-agent -a $SSH_AUTH_SOCK > /dev/null 42 | echo "echo $SSH_PASSPHRASE" > ~/.ssh_askpass && chmod +x ~/.ssh_askpass 43 | echo "$SSH_PRIVATE_KEY" | tr -d '\r' | DISPLAY=None SSH_ASKPASS=~/.ssh_askpass ssh-add - >/dev/null 44 | 45 | # - uses: webfactory/ssh-agent@v0.7.0 46 | # with: 47 | # ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 48 | 49 | - name: Set up Docker Buildx 50 | id: buildx 51 | uses: docker/setup-buildx-action@v2 52 | 53 | - name: Run deploy command 54 | run: kamal deploy 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | next 2 | node_modules 3 | .next/ 4 | db.sqlite* 5 | .env 6 | .env* 7 | !.env.erb -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | dist 5 | .next -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "arrowParens": "avoid", 4 | "semi": true, 5 | "singleQuote": true, 6 | "useTabs": true 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS base 2 | 3 | # Disabling Telemetry 4 | ENV NEXT_TELEMETRY_DISABLED 1 5 | RUN apk add --no-cache libc6-compat curl python3 py3-pip 6 | 7 | FROM base AS deps 8 | WORKDIR /app 9 | 10 | COPY package.json package-lock.json ./ 11 | RUN npm ci 12 | 13 | FROM base AS builder 14 | WORKDIR /app 15 | COPY --from=deps /app/node_modules ./node_modules 16 | COPY . . 17 | 18 | RUN npm run build 19 | 20 | FROM base AS runner 21 | WORKDIR /app 22 | 23 | ENV NODE_ENV production 24 | 25 | RUN addgroup --system --gid 1001 nodejs 26 | RUN adduser --system --uid 1001 nextjs 27 | 28 | COPY --from=builder /app/drizzle ./drizzle 29 | COPY --from=builder /app/public ./public 30 | RUN mkdir .next 31 | RUN mkdir -p /data && chown -R nextjs:nodejs /data 32 | RUN chown nextjs:nodejs .next 33 | 34 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 35 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 36 | 37 | USER nextjs 38 | 39 | EXPOSE 3000 40 | 41 | ENV PORT 3000 42 | ENV HOSTNAME "0.0.0.0" 43 | ENV NODE_ENV=production 44 | 45 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Do we need Vercel? Can we just run our NextJS apps on a VM? Maybe all our apps on one machine? 2 | 3 | I love NextJS, but I don't like Vercels pricing. It seems nuts to me and it seems many other people do too. So I spent a few hours playing with this and ~~Digital Ocean~~ Hetzner Cloud (€3.30/mo 🤣) to see what was possible. 4 | 5 | All thanks goes to [kamal](https://github.com/basecamp/kamal) and @ImSh4yy, I built this using his [post](https://logsnag.com/blog/self-host-nextjs-hetzner-kamal) 🙏 6 | 7 | ## What's the objective here? 8 | 9 | Figure out if we can have all the requirements of most indie hacker apps on a little VM instead of Vercel? 10 | 11 | Here's the list of to dos: 12 | 13 | - [x] Can we run NextJS on VPS easily? ✅ 14 | - [x] Is the latency acceptable? ✅ 35ms, roughly same as Vercel 15 | - [x] Can we auto deploy? ✅ see [deploy-on-main.yml](.github/workflows/deploy-on-main.yml) 16 | - [x] Can we persist data on this machine when using Docker? 17 | - [x] How much traffic can this machine handle concurrently? Around 750 HTTP requests/sec on Hetzner €3.29/mo VPS, before it starts to slow down, see this load test [report](https://loader.io/reports/e86c09956f73bb12f0e2b15900947a60/results/9ba8eb7e6dc70fd3966f3abed65e2166) 18 | - [x] What's the writes per second using SQL Lite? [✅ 14,000/sec on Hetzner €3.29/mo VPS](https://twitter.com/ashleyrudland/status/1777597718560444498) 19 | - [x] What's the uptime of this? ✅ so far 100% 20 | - [x] NextJS Feature: Image Optimization? ✅ works! 21 | - [x] NextJS Feature: Can we use Server Actions? ✅ SQLite write test runs on Server Actions. See [actions](./src/app/actions/) 22 | - [x] NextJS Feature: API routes? ✅ see [/api/vm/](./src/app/api/vm/) 23 | - [ ] NextJS Feature: Can we use the NextJS Caching? Custom Cache? 24 | - [ ] Can/how we run multiple apps on the same machine? Switch app based on domain name? 25 | 26 | ## What's not the objective? 27 | 28 | - Infinite scale - do indie hackers really need this? 29 | - Complex architecture 30 | 31 | ### How does this auto deploy? 32 | 33 | Basically GitHub actions run on each commit to main, builds image using Docker, uploads then Kamal connects to machine via SSH (with passphrase), then reboots app with new code. 34 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /config/deploy.yml: -------------------------------------------------------------------------------- 1 | # config/deploy.yml 2 | service: server 3 | image: ashleyrudland87/ash 4 | servers: 5 | - <%= ENV["VPS_HOSTNAME"] %> 6 | registry: 7 | username: 8 | - KAMAL_REGISTRY_USERNAME 9 | password: 10 | - KAMAL_REGISTRY_PASSWORD 11 | port: 3000 12 | volumes: 13 | - "data:/data" 14 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'drizzle-kit'; 2 | 3 | export default { 4 | schema: './src/models/schema.ts', 5 | out: './drizzle', 6 | driver: 'better-sqlite', 7 | dbCredentials: { 8 | url: 9 | process.env.NODE_ENV === 'production' 10 | ? '/data/db.sqlite3' 11 | : './db.sqlite3', 12 | }, 13 | } as Config; 14 | -------------------------------------------------------------------------------- /drizzle/0000_smart_stark_industries.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `comments` ( 2 | `id` integer PRIMARY KEY NOT NULL, 3 | `author` text NOT NULL, 4 | `content` text NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "id": "3f36423e-dce2-4b4a-b33a-c35f1dc9d74e", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "comments": { 8 | "name": "comments", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "author": { 18 | "name": "author", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "content": { 25 | "name": "content", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | } 31 | }, 32 | "indexes": {}, 33 | "foreignKeys": {}, 34 | "compositePrimaryKeys": {}, 35 | "uniqueConstraints": {} 36 | } 37 | }, 38 | "enums": {}, 39 | "_meta": { 40 | "schemas": {}, 41 | "tables": {}, 42 | "columns": {} 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1712488167409, 9 | "tag": "0000_smart_stark_industries", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: 'standalone', 4 | }; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs_vps", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "generate": "drizzle-kit generate:sqlite", 11 | "deploy": "kamal deploy", 12 | "pretty": "npx prettier --write .", 13 | "pretty:check": "npx prettier --check ." 14 | }, 15 | "dependencies": { 16 | "@radix-ui/react-slot": "^1.0.2", 17 | "better-sqlite3": "^9.4.5", 18 | "class-variance-authority": "^0.7.0", 19 | "clsx": "^2.1.0", 20 | "drizzle-orm": "^0.30.7", 21 | "install": "^0.13.0", 22 | "lodash": "^4.17.21", 23 | "lodash.chunk": "^4.2.0", 24 | "lucide-react": "^0.365.0", 25 | "moment": "^2.30.1", 26 | "next": "14.1.4", 27 | "npm": "^10.5.1", 28 | "react": "^18", 29 | "react-dom": "^18", 30 | "recharts": "^2.12.4", 31 | "serialize-error": "^11.0.3", 32 | "sharp": "^0.33.3", 33 | "sqlite": "^5.1.1", 34 | "stale-while-revalidate-cache": "^3.4.0", 35 | "tailwind-merge": "^2.2.2", 36 | "tailwindcss-animate": "^1.0.7" 37 | }, 38 | "devDependencies": { 39 | "@types/better-sqlite3": "^7.6.9", 40 | "@types/lodash.chunk": "^4.2.9", 41 | "@types/node": "^20", 42 | "@types/react": "^18", 43 | "@types/react-dom": "^18", 44 | "autoprefixer": "^10.0.1", 45 | "drizzle-kit": "^0.20.14", 46 | "eslint": "^8", 47 | "eslint-config-next": "14.1.4", 48 | "husky": "^9.0.11", 49 | "postcss": "^8", 50 | "prettier": "3.2.5", 51 | "tailwindcss": "^3.3.0", 52 | "typescript": "^5" 53 | }, 54 | "trustedDependencies": [ 55 | "es5-ext" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/public/.DS_Store -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs_vps/1b329903c1f8f2c20adaf5c5b98fef384c4814c2/public/favicon.ico -------------------------------------------------------------------------------- /public/loaderio-2de975c23c1a762501e85dc19b0ceabd.txt: -------------------------------------------------------------------------------- 1 | loaderio-2de975c23c1a762501e85dc19b0ceabd -------------------------------------------------------------------------------- /src/app/actions/db-test.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { stat } from 'fs/promises'; 4 | import { count, eq, sql } from 'drizzle-orm'; 5 | import chunk from 'lodash.chunk'; 6 | import { comments } from '@/models/schema'; 7 | import { DB_PATH, db } from '@/utils/db'; 8 | import { Comment } from '@/models/types'; 9 | 10 | import { createStaleWhileRevalidateCache } from 'stale-while-revalidate-cache'; 11 | 12 | /** 13 | * Executes an asynchronous function on chunks of an array concurrently. 14 | * @param array The array to be processed. 15 | * @param asyncFn The asynchronous function to be executed on each element of the array. 16 | * @param chunkSize The size of each chunk. Defaults to 50. 17 | * @returns A Promise that resolves when all chunks have been processed. 18 | */ 19 | export const throttleExec = async ( 20 | array: T[], 21 | asyncFn: (id: T) => Promise, 22 | chunkSize = 50, 23 | ) => { 24 | const chunks = chunk(array, chunkSize); 25 | for (const item of chunks) { 26 | await Promise.allSettled(item.map(asyncFn)); 27 | } 28 | }; 29 | 30 | /** 31 | * Inserts multiple comments into the database. 32 | * @param values An array of Comment objects to be inserted. 33 | * @returns An object containing the IDs of the new records, the number of writes, and the number of failures. 34 | */ 35 | async function insertComments(values: Comment[]) { 36 | const newRecords: number[] = []; 37 | let writes = 0; 38 | let failures = 0; 39 | 40 | const insertQuery = db 41 | .insert(comments) 42 | .values({ 43 | author: sql.placeholder('author'), 44 | content: sql.placeholder('content'), 45 | }) 46 | .prepare(); 47 | 48 | await throttleExec(values, async ({ author, content }) => { 49 | try { 50 | const { lastInsertRowid } = await insertQuery.execute({ 51 | author, 52 | content, 53 | }); 54 | newRecords.push(Number(lastInsertRowid)); 55 | writes++; 56 | } catch { 57 | failures++; 58 | } 59 | }); 60 | 61 | return { newRecords, writes, failures }; 62 | } 63 | 64 | /** 65 | * Measures the read performance by querying inserted records. 66 | * @param newRecords An array of IDs for the newly inserted records. 67 | * @returns An object containing the number of reads and the reads per second. 68 | */ 69 | async function measureReads(newRecords: number[]) { 70 | const readStart = Date.now(); 71 | 72 | const query = db.query.comments 73 | .findFirst({ where: eq(comments.id, sql.placeholder('id')) }) 74 | .prepare(); 75 | 76 | let reads = 0; 77 | await throttleExec(newRecords, async newId => { 78 | await query.execute({ id: newId }); 79 | reads++; 80 | }); 81 | 82 | const readTime = Date.now() - readStart; 83 | const readsPerSecond = Math.round(reads / (readTime / 1000)); 84 | 85 | return { reads, readsPerSecond }; 86 | } 87 | 88 | type TestResult = { 89 | dbSizeInMb: number; 90 | error?: string; 91 | failureRate: number; 92 | reads: number; 93 | readsPerSecond: number; 94 | total: number; 95 | writes: number; 96 | writesPerSecond: number; 97 | writeTime: number; 98 | }; 99 | 100 | /** 101 | * Performs a database test by inserting, reading, and deleting comments. 102 | * @returns The test results including write and read performance metrics. 103 | */ 104 | async function runTests(): Promise { 105 | const values = Array.from({ length: 350000 }, () => ({ 106 | author: Math.random().toString(36).substring(7), 107 | content: Math.random().toString(36).substring(7), 108 | })) as Comment[]; 109 | 110 | try { 111 | const start = Date.now(); 112 | const { newRecords, writes, failures } = await insertComments(values); 113 | const writeTime = Date.now() - start; 114 | const writesPerSecond = Math.round(writes / (writeTime / 1000)); 115 | const failureRate = Math.round((failures / writes) * 100); 116 | 117 | const { reads, readsPerSecond } = await measureReads(newRecords); 118 | 119 | const [{ count: total }] = await db 120 | .select({ count: count() }) 121 | .from(comments) 122 | .execute(); 123 | 124 | const dbSizeInMb = Math.round((await stat(DB_PATH)).size / 1024 / 1024); 125 | 126 | return { 127 | dbSizeInMb, 128 | failureRate, 129 | reads, 130 | readsPerSecond, 131 | total, 132 | writes, 133 | writesPerSecond, 134 | writeTime, 135 | }; 136 | } catch (e: any) { 137 | console.log(e); 138 | return undefined; 139 | } 140 | } 141 | 142 | const map = new Map(); 143 | const swr = createStaleWhileRevalidateCache({ 144 | storage: { 145 | getItem: (key: string) => { 146 | return map.get(key); 147 | }, 148 | setItem: async (key: string, value: unknown) => { 149 | map.set(key, value); 150 | }, 151 | }, 152 | }); 153 | 154 | /** 155 | * Executes database tests using a caching mechanism to avoid frequent re-execution. 156 | * It leverages the stale-while-revalidate strategy to manage cache freshness. 157 | * @returns The result of the database tests including performance metrics. 158 | * @throws Throws an error if the test fails to run or returns an undefined result. 159 | */ 160 | export async function dbTest(): Promise { 161 | const result = await swr('test', async () => await runTests(), { 162 | maxTimeToLive: 1000 * 60 * 2, // 2 minutes 163 | minTimeToStale: 1000 * 60 * 1, // 1 minute 164 | }); 165 | 166 | if (result.value === undefined) { 167 | throw new Error('Failed to run tests'); 168 | } 169 | 170 | return result.value; 171 | } 172 | -------------------------------------------------------------------------------- /src/app/api/vm/device/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import os from 'os'; 3 | 4 | export async function GET() { 5 | cookies().get('auth'); 6 | let start = Date.now(); 7 | const platform = `${os.platform()}, ${os.arch()}, ${os.release()}`; 8 | console.log('platform took', Date.now() - start); 9 | 10 | start = Date.now(); 11 | const totalMemory = os.totalmem(); 12 | console.log('total mem took', Date.now() - start); 13 | 14 | start = Date.now(); 15 | const cpuList = os.cpus(); 16 | console.log('cpu list took', Date.now() - start); 17 | 18 | return Response.json({ 19 | when: new Date().toISOString(), 20 | platform, 21 | totalMemory, 22 | cpuCount: cpuList.length, 23 | cpuModel: cpuList[0].model, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/api/vm/usage/route.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | 3 | import { takeRight } from 'lodash'; 4 | import { cookies } from 'next/headers'; 5 | import { NextRequest } from 'next/server'; 6 | 7 | const totalMemory = os.totalmem(); 8 | 9 | type Telemetry = { 10 | memoryUsage: number; 11 | cpuUsage: number; 12 | when: Date; 13 | }; 14 | 15 | let telemetry: Telemetry[] = []; 16 | 17 | export async function GET(request: NextRequest) { 18 | cookies().get('auth'); 19 | 20 | const cpus = os.cpus(); 21 | const cpuUsageAmounts = cpus.map(cpu => { 22 | const total = Object.values(cpu.times).reduce((acc, tv) => acc + tv, 0); 23 | const usage = ((total - cpu.times.idle) / total) * 100; 24 | return usage; 25 | }); 26 | const cpuUsage = Math.round( 27 | cpuUsageAmounts.reduce((acc, usage) => acc + usage, 0) / cpus.length, 28 | ); 29 | 30 | const freeMemory = os.freemem(); 31 | const memoryUsage = Math.round( 32 | ((totalMemory - freeMemory) / totalMemory) * 100, 33 | ); 34 | 35 | telemetry.push({ 36 | memoryUsage, 37 | cpuUsage, 38 | when: new Date(), 39 | }); 40 | 41 | telemetry.sort((a, b) => a.when.getTime() - b.when.getTime()); 42 | 43 | if (telemetry.length > 30) { 44 | telemetry = takeRight(telemetry, 100); 45 | } 46 | 47 | console.log({ 48 | ...telemetry[telemetry.length - 1], 49 | }); 50 | 51 | return Response.json({ 52 | when: new Date().toISOString(), 53 | telemetry, 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | @keyframes animatedGradient { 79 | 0% { 80 | background-position: 0% 50%; 81 | } 82 | 50% { 83 | background-position: 100% 50%; 84 | } 85 | 100% { 86 | background-position: 0% 50%; 87 | } 88 | } 89 | 90 | .custom-gradient { 91 | background: linear-gradient(60deg, #f79533, #f37055, #ef4e7b, #a166ab, #5073b8, #1098ad, #07b39b, #6fba82); 92 | animation: animatedGradient 6s ease infinite alternate; 93 | background-size: 300% 300%; 94 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter as FontSans } from 'next/font/google'; 3 | import './globals.css'; 4 | import { cn } from '@/lib/utils'; 5 | 6 | const fontSans = FontSans({ 7 | subsets: ['latin'], 8 | variable: '--font-sans', 9 | }); 10 | 11 | export const metadata: Metadata = { 12 | title: "VPS vs Vercel - Ashley Rudland's VPS Playground", 13 | description: 'Do we need Vercel? Can we just run our NextJS apps on a VM?', 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: Readonly<{ 19 | children: React.ReactNode; 20 | }>) { 21 | return ( 22 | 23 | 29 | {children} 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Capacity from '@/components/tests/capacity'; 2 | import DbTest from '@/components/tests/db'; 3 | import Images from '@/components/tests/images'; 4 | import { ArrowRightIcon } from 'lucide-react'; 5 | 6 | export default function Home() { 7 | return ( 8 | <> 9 | 14 | Want to create the same VPS that I did?{' '} 15 | 16 | Check out my guide on how to do it 17 | 18 | 19 | 20 |
21 |

22 | VPS vs Vercel - Ashley Rudland's VPS Playground 23 |

24 | 25 |

26 | Source code:{' '} 27 | 33 | GitHub 34 | 35 |

36 |

37 | Do we need Vercel? ( 38 | 43 | it seems not 44 | 45 | ) Can we just run our NextJS apps on a VM? Maybe all our 46 | apps on one machine? 47 |

48 | 49 |

50 | I love NextJS, but I don't like Vercels pricing. It seems 51 | nuts to me and it seems many other people do too. So I spent 52 | a few hours playing with this and Hetzner Cloud (€3.30/mo 53 | 🤣) to see what was possible. 54 |

55 | 56 |
57 | 58 | 59 |
60 |
61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/app/up/route.ts: -------------------------------------------------------------------------------- 1 | console.log(`${new Date().toISOString()}: /up route.ts loaded`); 2 | 3 | export async function GET() { 4 | console.log(`${new Date().toISOString()}: /up GET hit`); 5 | return new Response('Ok', { status: 200 }); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/tests/capacity.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | 5 | import Chart from './chart'; 6 | import { TestCard } from '@/components/ui/test-card'; 7 | import { Spinner } from '@/components/ui/spinner'; 8 | 9 | function secondsToTime(seconds: number) { 10 | const hours = Math.floor(seconds / 3600); 11 | const minutes = Math.floor((seconds % 3600) / 60); 12 | const remainingSeconds = Math.round(seconds % 60); 13 | return `${hours}hr ${minutes}m ${remainingSeconds}s`; 14 | } 15 | 16 | const Capacity = () => { 17 | const [loading, setLoading] = useState(true); 18 | const [error, setError] = useState(null); 19 | const [uptime, setUptime] = useState(0); 20 | const [device, setDevice] = useState<{ 21 | cpuCount: number; 22 | cpuModel: string; 23 | name: string; 24 | platform: string; 25 | totalMemory: number; 26 | processes?: { name: string; memoryPercentage: number }[]; 27 | } | null>(null); 28 | const [usage, setUsage] = useState< 29 | { 30 | cpuUsage: number; 31 | memoryUsage: number; 32 | when: string; 33 | }[] 34 | >([]); 35 | 36 | useEffect(() => { 37 | // @ts-expect-error run test in browser 38 | if (typeof window === 'undefined' || window.runCapacityTest) return; 39 | // @ts-expect-error run test in browser 40 | window.runCapacityTest = true; 41 | setLoading(true); 42 | setError(null); 43 | 44 | let timeout: NodeJS.Timeout | null = null; 45 | 46 | fetch(`/api/vm/device?cacheBuster=${Math.random()}`) 47 | .then(res => res.json()) 48 | .then(res => setDevice(res)) 49 | .catch(() => setError('Failed to fetch device info')); 50 | 51 | const getUsage = () => { 52 | fetch(`/api/vm/usage?cacheBuster=${Math.random()}`) 53 | .then(res => res.json()) 54 | .then(res => { 55 | setUsage(res.telemetry); 56 | setLoading(false); 57 | timeout = setTimeout(getUsage, 5000); 58 | }) 59 | .catch(() => setError('Failed to fetch device info')); 60 | }; 61 | 62 | getUsage(); 63 | return () => { 64 | if (timeout) clearTimeout(timeout); 65 | }; 66 | }, []); 67 | 68 | return ( 69 | 73 | {loading && ( 74 |
75 | 76 | Getting info... 77 |
78 | )} 79 | {!loading && ( 80 | <> 81 |
    82 | {device && ( 83 | <> 84 |
  • vCPUs: {device.cpuCount}
  • 85 |
  • CPU model: {device.cpuModel}
  • 86 |
  • Platform: {device.platform}
  • 87 |
  • 88 | Total RAM:{' '} 89 | {( 90 | device.totalMemory / 91 | 1024 / 92 | 1024 / 93 | 1024 94 | ).toFixed(1)} 95 | GB 96 |
  • 97 | 98 | {device.processes && 99 | device.processes.length > 0 && ( 100 | <> 101 | {device.processes.map( 102 | ({ 103 | name, 104 | memoryPercentage, 105 | }) => ( 106 |
  • 107 | {name.length > 108 | 10 109 | ? name.substring( 110 | 0, 111 | 10, 112 | ) + 113 | '...' 114 | : name} 115 | :{' '} 116 | { 117 | memoryPercentage 118 | } 119 | % 120 |
  • 121 | ), 122 | )} 123 | 124 | )} 125 | 126 | )} 127 | 128 | {usage && usage.length > 0 && ( 129 | <> 130 |
  • 131 | CPU usage:{' '} 132 | {usage[usage.length - 1].cpuUsage}% 133 |
  • 134 |
  • 135 | Memory usage:{' '} 136 | { 137 | usage[usage.length - 1] 138 | .memoryUsage 139 | } 140 | % 141 |
  • 142 | 143 | )} 144 |
145 | 146 | )} 147 | {error &&

{error}

} 148 | 149 | } 150 | /> 151 | ); 152 | }; 153 | 154 | export default Capacity; 155 | -------------------------------------------------------------------------------- /src/components/tests/chart.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import moment from 'moment'; 4 | import { 5 | LineChart, 6 | Line, 7 | Tooltip, 8 | Legend, 9 | CartesianGrid, 10 | XAxis, 11 | YAxis, 12 | } from 'recharts'; 13 | 14 | type Telemetry = { 15 | cpuUsage: number; 16 | memoryUsage: number; 17 | when: string; 18 | }; 19 | 20 | export default function Chart({ data }: { data: Telemetry[] }) { 21 | return ( 22 |
23 | 34 | 35 | 38 | moment(value as string).format('HH:mm:ss') 39 | } 40 | /> 41 | `${Math.round(value)}%`} /> 42 | (value ? `${value as number}%` : '0%')} 44 | /> 45 | 46 | 52 | 58 | 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/tests/db.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useState, useEffect } from 'react'; 4 | 5 | import { Spinner } from '@/components/ui/spinner'; 6 | import { TestCard } from '@/components/ui/test-card'; 7 | 8 | import { dbTest } from '@/app/actions/db-test'; 9 | 10 | const DbTest = () => { 11 | const [loading, setLoading] = useState(true); 12 | const [error, setError] = useState(null); 13 | const [result, setResult] = useState 15 | > | null>(); 16 | const [startTime, setStartTime] = useState(0); 17 | const [runningTime, setRunningTime] = useState(0); 18 | 19 | const runTest = useCallback(async () => { 20 | setRunningTime(0); 21 | setStartTime(Date.now()); 22 | setLoading(true); 23 | setError(null); 24 | setResult(null); 25 | dbTest() 26 | .then(res => { 27 | setResult(res); 28 | setError(res.error); 29 | setLoading(false); 30 | }) 31 | .catch(error => { 32 | console.error(error); 33 | setError(error); 34 | setLoading(false); 35 | }); 36 | }, []); 37 | 38 | useEffect(() => { 39 | let timeout: NodeJS.Timeout | null = null; 40 | 41 | const cleanup = () => { 42 | if (timeout) { 43 | clearTimeout(timeout); 44 | timeout = null; 45 | } 46 | }; 47 | 48 | if (startTime && loading) { 49 | timeout = setInterval(() => { 50 | if (!loading) cleanup(); 51 | if (startTime) { 52 | setRunningTime(Date.now() - startTime); 53 | } 54 | }, 200); 55 | } 56 | 57 | return cleanup; 58 | }, [startTime, loading]); 59 | 60 | useEffect(() => { 61 | // @ts-expect-error run test in browser 62 | if (typeof window !== 'undefined' && !window.runDbTest) { 63 | // @ts-expect-error run test in browser 64 | window.runDbTest = true; 65 | setTimeout(() => runTest(), 1000); 66 | } 67 | }, [runTest]); 68 | 69 | return ( 70 | 74 | {loading && ( 75 |
76 | 77 | 78 | Running test ({(runningTime / 1000).toFixed(1)} 79 | s)... 80 | 81 |
82 | )} 83 | {!loading && result && ( 84 |
    85 |
  • 86 | DB size:{' '} 87 | {result.dbSizeInMb >= 1024 88 | ? `${(result.dbSizeInMb / 1024).toLocaleString(undefined, { maximumFractionDigits: 1 })}GB` 89 | : `${result.dbSizeInMb.toLocaleString()}MB`} 90 |
  • 91 |
  • 92 | Table size: {result.total.toLocaleString()}{' '} 93 | records 94 |
  • 95 |
  • 96 | Reads/sec:{' '} 97 | {result.readsPerSecond.toLocaleString()} 98 |
  • 99 |
  • 100 | Writes/sec:{' '} 101 | {result.writesPerSecond.toLocaleString()} 102 |
  • 103 | {result.failureRate > 0 && ( 104 |
  • Failure rate: {result.failureRate}%
  • 105 | )} 106 |
107 | )} 108 | {error && ( 109 | <> 110 | {typeof error === 'string' && error} 111 | {typeof error !== 'string' && JSON.stringify(error)} 112 | 113 | )} 114 | 115 | } 116 | /> 117 | ); 118 | }; 119 | 120 | export default DbTest; 121 | -------------------------------------------------------------------------------- /src/components/tests/images.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import Image from 'next/image'; 5 | 6 | import { TestCard } from '@/components/ui/test-card'; 7 | 8 | const Images = () => { 9 | const [imageUrl, setImageUrl] = useState(''); 10 | return ( 11 | 15 |

16 | Test NextJS Optimised Image setImageUrl(e.currentTarget.src)} 22 | /> 23 |

24 |

25 | {imageUrl 26 | ? `✅ NextJS image optimisation works. src: ${imageUrl}` 27 | : 'Image loading'} 28 |

29 | 30 | } 31 | /> 32 | ); 33 | }; 34 | 35 | export default Images; 36 | -------------------------------------------------------------------------------- /src/components/ui/button-loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from './spinner'; 2 | import { Button } from './button'; 3 | 4 | export default function ButtonLoading() { 5 | return ( 6 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-primary text-primary-foreground hover:bg-primary/90', 14 | destructive: 15 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 16 | outline: 17 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 18 | secondary: 19 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 20 | ghost: 'hover:bg-accent hover:text-accent-foreground', 21 | link: 'text-primary underline-offset-4 hover:underline', 22 | }, 23 | size: { 24 | default: 'h-10 px-4 py-2', 25 | sm: 'h-9 rounded-md px-3', 26 | lg: 'h-11 rounded-md px-8', 27 | icon: 'h-10 w-10', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : 'button'; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = 'Button'; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = 'Card'; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = 'CardHeader'; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )); 45 | CardTitle.displayName = 'CardTitle'; 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )); 57 | CardDescription.displayName = 'CardDescription'; 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )); 65 | CardContent.displayName = 'CardContent'; 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )); 77 | CardFooter.displayName = 'CardFooter'; 78 | 79 | export { 80 | Card, 81 | CardHeader, 82 | CardFooter, 83 | CardTitle, 84 | CardDescription, 85 | CardContent, 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/ui/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from 'lucide-react'; 2 | 3 | export const Spinner = () => ; 4 | -------------------------------------------------------------------------------- /src/components/ui/test-card.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardFooter, 8 | CardHeader, 9 | CardTitle, 10 | } from '@/components/ui/card'; 11 | 12 | export const TestCard = ({ 13 | name, 14 | content, 15 | footer, 16 | }: { 17 | name: ReactNode; 18 | content: ReactNode; 19 | footer?: ReactNode; 20 | }) => { 21 | return ( 22 | 23 | 24 | {name} 25 | 26 | 27 | 28 | {content} 29 | 30 | {footer && {footer}} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/models/schema.ts: -------------------------------------------------------------------------------- 1 | // src/models/schema.ts 2 | import { text, sqliteTable, integer } from 'drizzle-orm/sqlite-core'; 3 | 4 | export const comments = sqliteTable('comments', { 5 | id: integer('id').primaryKey(), 6 | author: text('author').notNull(), 7 | content: text('content').notNull(), 8 | }); 9 | -------------------------------------------------------------------------------- /src/models/types.ts: -------------------------------------------------------------------------------- 1 | import type { InferSelectModel } from 'drizzle-orm'; 2 | import { comments } from './schema'; 3 | 4 | export type Comment = InferSelectModel; 5 | -------------------------------------------------------------------------------- /src/utils/db.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/better-sqlite3'; 2 | import Database from 'better-sqlite3'; 3 | import * as schema from '../models/schema'; 4 | import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; 5 | 6 | export const DB_PATH = 7 | process.env.NODE_ENV === 'production' ? '/data/db.sqlite3' : './db.sqlite3'; 8 | 9 | const sqlite = new Database(DB_PATH); 10 | 11 | // Set some performance parameters for SQLite 12 | // found at: https://www.youtube.com/watch?v=B-_P0d1el2k 13 | // copied from: https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb 14 | const sqlitePerfParams = { 15 | foreign_keys: true, 16 | journal_mode: 'WAL', 17 | synchronous: 'normal', 18 | mmap_size: 134217728, // 128 megabytes 19 | journal_size_limit: 67108864, // 64 megabytes 20 | cache_size: 2000, 21 | busy_timeout: 30000, 22 | }; 23 | 24 | for (const [key, value] of Object.entries(sqlitePerfParams)) { 25 | sqlite.pragma(`${key} = ${value}`); 26 | } 27 | 28 | export const db = drizzle(sqlite, { schema }); 29 | 30 | migrate(db, { migrationsFolder: './drizzle' }); 31 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const { fontFamily } = require('tailwindcss/defaultTheme'); 3 | 4 | module.exports = { 5 | darkMode: ['class'], 6 | content: [ 7 | './pages/**/*.{ts,tsx}', 8 | './components/**/*.{ts,tsx}', 9 | './app/**/*.{ts,tsx}', 10 | './src/**/*.{ts,tsx}', 11 | ], 12 | prefix: '', 13 | theme: { 14 | container: { 15 | center: true, 16 | padding: '2rem', 17 | screens: { 18 | '2xl': '1400px', 19 | }, 20 | }, 21 | extend: { 22 | colors: { 23 | border: 'hsl(var(--border))', 24 | input: 'hsl(var(--input))', 25 | ring: 'hsl(var(--ring))', 26 | background: 'hsl(var(--background))', 27 | foreground: 'hsl(var(--foreground))', 28 | primary: { 29 | DEFAULT: 'hsl(var(--primary))', 30 | foreground: 'hsl(var(--primary-foreground))', 31 | }, 32 | secondary: { 33 | DEFAULT: 'hsl(var(--secondary))', 34 | foreground: 'hsl(var(--secondary-foreground))', 35 | }, 36 | destructive: { 37 | DEFAULT: 'hsl(var(--destructive))', 38 | foreground: 'hsl(var(--destructive-foreground))', 39 | }, 40 | muted: { 41 | DEFAULT: 'hsl(var(--muted))', 42 | foreground: 'hsl(var(--muted-foreground))', 43 | }, 44 | accent: { 45 | DEFAULT: 'hsl(var(--accent))', 46 | foreground: 'hsl(var(--accent-foreground))', 47 | }, 48 | popover: { 49 | DEFAULT: 'hsl(var(--popover))', 50 | foreground: 'hsl(var(--popover-foreground))', 51 | }, 52 | card: { 53 | DEFAULT: 'hsl(var(--card))', 54 | foreground: 'hsl(var(--card-foreground))', 55 | }, 56 | }, 57 | borderRadius: { 58 | lg: 'var(--radius)', 59 | md: 'calc(var(--radius) - 2px)', 60 | sm: 'calc(var(--radius) - 4px)', 61 | }, 62 | keyframes: { 63 | 'accordion-down': { 64 | from: { height: '0' }, 65 | to: { height: 'var(--radix-accordion-content-height)' }, 66 | }, 67 | 'accordion-up': { 68 | from: { height: 'var(--radix-accordion-content-height)' }, 69 | to: { height: '0' }, 70 | }, 71 | }, 72 | animation: { 73 | 'accordion-down': 'accordion-down 0.2s ease-out', 74 | 'accordion-up': 'accordion-up 0.2s ease-out', 75 | }, 76 | fontFamily: { 77 | sans: ['var(--font-sans)', ...fontFamily.sans], 78 | }, 79 | }, 80 | }, 81 | plugins: [require('tailwindcss-animate')], 82 | }; 83 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------