Optimize docker image size (#91)

* Move prisma to runtime dependencies

* Optimize Dockerfile and build script

* Fix: remove mention of generated next-env.d.ts in Dockerfile

* Add missing reset.d.ts file to Dockerfile

* Remove compression steps from Dockerfile and entrypoint script

* Add an env file with mocked env vars added for Docker production builds

* Use server actions to get runtime env vars

* Refactor types and names

* Rollback serverActions, use parsed Zod object for runtime env

* Reintroduce featureFlags object to avoid passing secret envs to the frontend

* Improve string to boolean coercion

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>

* Run prettier autoformat

* Fix type issue, rename function to match behaviour better

---------

Co-authored-by: Lauri Vuorela <lauri.vuorela@gmail.com>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
This commit is contained in:
Jan T
2024-02-14 17:18:30 +02:00
committed by GitHub
parent 50525ad881
commit 2af0660383
12 changed files with 146 additions and 77 deletions

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ yarn-error.log*
# local env files # local env files
.env*.local .env*.local
*.env *.env
!scripts/build.env
# vercel # vercel
.vercel .vercel

View File

@@ -1,22 +1,47 @@
FROM node:21-slim as base FROM node:21-alpine as base
WORKDIR /usr/app
COPY ./package.json \
./package-lock.json \
./next.config.js \
./tsconfig.json \
./reset.d.ts \
./tailwind.config.js \
./postcss.config.js ./
COPY ./scripts ./scripts
COPY ./prisma ./prisma
COPY ./src ./src
RUN apk add --no-cache openssl && \
npm ci --ignore-scripts && \
npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
COPY scripts/build.env .env
RUN npm run build
RUN rm -r .next/cache
FROM node:21-alpine as runtime-deps
WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./
COPY --from=base /usr/app/prisma ./prisma
RUN npm ci --omit=dev --omit=optional --ignore-scripts && \
npx prisma generate
FROM node:21-alpine as runner
EXPOSE 3000/tcp EXPOSE 3000/tcp
WORKDIR /usr/app WORKDIR /usr/app
COPY ./ ./
RUN apt update && \ COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./
apt install openssl -y && \ COPY --from=runtime-deps /usr/app/node_modules ./node_modules
apt clean && \ COPY ./public ./public
apt autoclean && \ COPY ./scripts ./scripts
apt autoremove && \ COPY --from=base /usr/app/prisma ./prisma
npm ci --ignore-scripts && \ COPY --from=base /usr/app/.next ./.next
npm install -g prisma && \
prisma generate
# env vars needed for build not to fail ENTRYPOINT ["/bin/sh", "/usr/app/scripts/container-entrypoint.sh"]
ARG POSTGRES_PRISMA_URL
ARG POSTGRES_URL_NON_POOLING
RUN npm run build
ENTRYPOINT ["/usr/app/scripts/container-entrypoint.sh"]

83
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"next13-progressbar": "^1.1.1", "next13-progressbar": "^1.1.1",
"openai": "^4.25.0", "openai": "^4.25.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"prisma": "^5.7.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
@@ -65,7 +66,6 @@
"postcss": "^8", "postcss": "^8",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3", "prettier-plugin-organize-imports": "^3.2.3",
"prisma": "^5.7.0",
"tailwindcss": "^3", "tailwindcss": "^3",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
@@ -1429,13 +1429,10 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.6.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.9.1.tgz",
"integrity": "sha512-mUDefQFa1wWqk4+JhKPYq8BdVoFk9NFMBXUI8jAkBfQTtgx8WPx02U2HB/XbAz3GSUJpeJOKJQtNvaAIDs6sug==", "integrity": "sha512-caSOnG4kxcSkhqC/2ShV7rEoWwd3XrftokxJqOCMVvia4NYV/TPtJlS9C2os3Igxw/Qyxumj9GBQzcStzECvtQ==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": {
"@prisma/engines-version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee"
},
"engines": { "engines": {
"node": ">=16.13" "node": ">=16.13"
}, },
@@ -1449,59 +1446,48 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.9.1.tgz",
"integrity": "sha512-tZ+MOjWlVvz1kOEhNYMa4QUGURY+kgOUBqLHYIV8jmCsMuvA1tWcn7qtIMLzYWCbDcQT4ZS8xDgK0R2gl6/0wA==", "integrity": "sha512-yAHFSFCg8KVoL0oRUno3m60GAjsUKYUDkQ+9BA2X2JfVR3kRVSJFc/GpQ2fSORi4pSHZR9orfM4UC9OVXIFFTA=="
"devOptional": true
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.9.1.tgz",
"integrity": "sha512-TkOMgMm60n5YgEKPn9erIvFX2/QuWnl3GBo6yTRyZKk5O5KQertXiNnrYgSLy0SpsKmhovEPQb+D4l0SzyE7XA==", "integrity": "sha512-gkdXmjxQ5jktxWNdDA5aZZ6R8rH74JkoKq6LD5mACSvxd2vbqWeWIOV0Py5wFC8vofOYShbt6XUeCIUmrOzOnQ==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.7.0", "@prisma/debug": "5.9.1",
"@prisma/engines-version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", "@prisma/engines-version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
"@prisma/fetch-engine": "5.7.0", "@prisma/fetch-engine": "5.9.1",
"@prisma/get-platform": "5.7.0" "@prisma/get-platform": "5.9.1"
} }
}, },
"node_modules/@prisma/engines-version": {
"version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz",
"integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw=="
},
"node_modules/@prisma/engines/node_modules/@prisma/engines-version": { "node_modules/@prisma/engines/node_modules/@prisma/engines-version": {
"version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", "version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64.tgz",
"integrity": "sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==", "integrity": "sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ=="
"devOptional": true
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.9.1.tgz",
"integrity": "sha512-zIn/qmO+N/3FYe7/L9o+yZseIU8ivh4NdPKSkQRIHfg2QVTVMnbhGoTcecbxfVubeTp+DjcbjS0H9fCuM4W04w==", "integrity": "sha512-l0goQOMcNVOJs1kAcwqpKq3ylvkD9F04Ioe1oJoCqmz05mw22bNAKKGWuDd3zTUoUZr97va0c/UfLNru+PDmNA==",
"devOptional": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.7.0", "@prisma/debug": "5.9.1",
"@prisma/engines-version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", "@prisma/engines-version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
"@prisma/get-platform": "5.7.0" "@prisma/get-platform": "5.9.1"
} }
}, },
"node_modules/@prisma/fetch-engine/node_modules/@prisma/engines-version": { "node_modules/@prisma/fetch-engine/node_modules/@prisma/engines-version": {
"version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", "version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64.tgz",
"integrity": "sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==", "integrity": "sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ=="
"devOptional": true
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.9.1.tgz",
"integrity": "sha512-ZeV/Op4bZsWXuw5Tg05WwRI8BlKiRFhsixPcAM+5BKYSiUZiMKIi713tfT3drBq8+T0E1arNZgYSA9QYcglWNA==", "integrity": "sha512-6OQsNxTyhvG+T2Ksr8FPFpuPeL4r9u0JF0OZHUBI/Uy9SS43sPyAIutt4ZEAyqWQt104ERh70EZedkHZKsnNbg==",
"devOptional": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.7.0" "@prisma/debug": "5.9.1"
} }
}, },
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
@@ -7430,13 +7416,12 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.7.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.9.1.tgz",
"integrity": "sha512-0rcfXO2ErmGAtxnuTNHQT9ztL0zZheQjOI/VNJzdq87C3TlGPQtMqtM+KCwU6XtmkoEr7vbCQqA7HF9IY0ST+Q==", "integrity": "sha512-Hy/8KJZz0ELtkw4FnG9MS9rNWlXcJhf98Z2QMqi0QiVMoS8PzsBkpla0/Y5hTlob8F3HeECYphBjqmBxrluUrQ==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/engines": "5.7.0" "@prisma/engines": "5.9.1"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

View File

@@ -53,7 +53,8 @@
"ts-pattern": "^5.0.6", "ts-pattern": "^5.0.6",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vaul": "^0.8.0", "vaul": "^0.8.0",
"zod": "^3.22.4" "zod": "^3.22.4",
"prisma": "^5.7.0"
}, },
"devDependencies": { "devDependencies": {
"@total-typescript/ts-reset": "^0.5.1", "@total-typescript/ts-reset": "^0.5.1",
@@ -70,7 +71,6 @@
"postcss": "^8", "postcss": "^8",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3", "prettier-plugin-organize-imports": "^3.2.3",
"prisma": "^5.7.0",
"tailwindcss": "^3", "tailwindcss": "^3",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"

View File

@@ -5,9 +5,6 @@ SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
# we need to set dummy data for POSTGRES env vars in order for build not to fail # we need to set dummy data for POSTGRES env vars in order for build not to fail
docker buildx build \ docker buildx build \
--no-cache \
--build-arg POSTGRES_PRISMA_URL=postgresql://build:@db \
--build-arg POSTGRES_URL_NON_POOLING=postgresql://build:@db \
-t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \ -t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \
-t ${SPLIIT_APP_NAME}:latest \ -t ${SPLIIT_APP_NAME}:latest \
. .

22
scripts/build.env Normal file
View File

@@ -0,0 +1,22 @@
# build file that contains all possible env vars with mocked values
# as most of them are used at build time in order to have the production build to work properly
# db
POSTGRES_PASSWORD=1234
# app
POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db
POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db
# app-minio
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=false
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_BUCKET=spliit
S3_UPLOAD_REGION=eu-north-1
S3_UPLOAD_ENDPOINT=s3://minio.example.com
# app-openai
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=false
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=false

View File

@@ -1,3 +1,6 @@
#!/bin/bash #!/bin/bash
prisma migrate deploy
set -euxo pipefail
npx prisma migrate deploy
npm run start npm run start

View File

@@ -6,6 +6,7 @@ import {
getExpense, getExpense,
updateExpense, updateExpense,
} from '@/lib/api' } from '@/lib/api'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas' import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next' import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
@@ -47,6 +48,7 @@ export default async function EditExpensePage({
categories={categories} categories={categories}
onSubmit={updateExpenseAction} onSubmit={updateExpenseAction}
onDelete={deleteExpenseAction} onDelete={deleteExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/> />
</Suspense> </Suspense>
) )

View File

@@ -1,6 +1,7 @@
import { cached } from '@/app/cached-functions' import { cached } from '@/app/cached-functions'
import { ExpenseForm } from '@/components/expense-form' import { ExpenseForm } from '@/components/expense-form'
import { createExpense, getCategories } from '@/lib/api' import { createExpense, getCategories } from '@/lib/api'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas' import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next' import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
@@ -32,6 +33,7 @@ export default async function ExpensePage({
group={group} group={group}
categories={categories} categories={categories}
onSubmit={createExpenseAction} onSubmit={createExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/> />
</Suspense> </Suspense>
) )

View File

@@ -35,6 +35,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api' import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@@ -52,6 +53,7 @@ export type Props = {
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>> categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues) => Promise<void> onSubmit: (values: ExpenseFormValues) => Promise<void>
onDelete?: () => Promise<void> onDelete?: () => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
} }
export function ExpenseForm({ export function ExpenseForm({
@@ -60,6 +62,7 @@ export function ExpenseForm({
categories, categories,
onSubmit, onSubmit,
onDelete, onDelete,
runtimeFeatureFlags,
}: Props) { }: Props) {
const isCreate = expense === undefined const isCreate = expense === undefined
const searchParams = useSearchParams() const searchParams = useSearchParams()
@@ -161,7 +164,7 @@ export function ExpenseForm({
{...field} {...field}
onBlur={async () => { onBlur={async () => {
field.onBlur() // avoid skipping other blur event listeners since we overwrite `field` field.onBlur() // avoid skipping other blur event listeners since we overwrite `field`
if (process.env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT) { if (runtimeFeatureFlags.enableCategoryExtract) {
setCategoryLoading(true) setCategoryLoading(true)
const { categoryId } = await extractCategoryFromTitle( const { categoryId } = await extractCategoryFromTitle(
field.value, field.value,
@@ -541,7 +544,7 @@ export function ExpenseForm({
</CardContent> </CardContent>
</Card> </Card>
{process.env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS && ( {runtimeFeatureFlags.enableExpenseDocuments && (
<Card className="mt-4"> <Card className="mt-4">
<CardHeader> <CardHeader>
<CardTitle className="flex justify-between"> <CardTitle className="flex justify-between">

View File

@@ -1,5 +1,10 @@
import { ZodIssueCode, z } from 'zod' import { ZodIssueCode, z } from 'zod'
const interpretEnvVarAsBool = (val: unknown): boolean => {
if (typeof val !== 'string') return false
return ['true', 'yes', '1', 'on'].includes(val.toLowerCase())
}
const envSchema = z const envSchema = z
.object({ .object({
POSTGRES_URL_NON_POOLING: z.string().url(), POSTGRES_URL_NON_POOLING: z.string().url(),
@@ -12,14 +17,23 @@ const envSchema = z
? `https://${process.env.VERCEL_URL}` ? `https://${process.env.VERCEL_URL}`
: 'http://localhost:3000', : 'http://localhost:3000',
), ),
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.coerce.boolean().default(false), NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.preprocess(
interpretEnvVarAsBool,
z.boolean().default(false),
),
S3_UPLOAD_KEY: z.string().optional(), S3_UPLOAD_KEY: z.string().optional(),
S3_UPLOAD_SECRET: z.string().optional(), S3_UPLOAD_SECRET: z.string().optional(),
S3_UPLOAD_BUCKET: z.string().optional(), S3_UPLOAD_BUCKET: z.string().optional(),
S3_UPLOAD_REGION: z.string().optional(), S3_UPLOAD_REGION: z.string().optional(),
S3_UPLOAD_ENDPOINT: z.string().optional(), S3_UPLOAD_ENDPOINT: z.string().optional(),
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.coerce.boolean().default(false), NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.preprocess(
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT: z.coerce.boolean().default(false), interpretEnvVarAsBool,
z.boolean().default(false),
),
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT: z.preprocess(
interpretEnvVarAsBool,
z.boolean().default(false),
),
OPENAI_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(),
}) })
.superRefine((env, ctx) => { .superRefine((env, ctx) => {

15
src/lib/featureFlags.ts Normal file
View File

@@ -0,0 +1,15 @@
'use server'
import { env } from './env'
export async function getRuntimeFeatureFlags() {
return {
enableExpenseDocuments: env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS,
enableReceiptExtract: env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT,
enableCategoryExtract: env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT,
}
}
export type RuntimeFeatureFlags = Awaited<
ReturnType<typeof getRuntimeFeatureFlags>
>