From 26d0ef9ac9a0ee4e14f65c6997d5c2566889c3c4 Mon Sep 17 00:00:00 2001
From: Guillaume Bonnet <mrsquaare@mrsquaare.fr>
Date: Mon, 15 Aug 2022 09:56:34 +0000
Subject: [PATCH] feat: add GlitchTip service

---
 README.md                                     |   1 +
 .../20220815092230_glitchtip/migration.sql    |  30 +++
 apps/api/prisma/schema.prisma                 |  28 ++
 apps/api/src/lib/common.ts                    |  41 ++-
 apps/api/src/lib/serviceFields.ts             | 112 ++++++++
 .../src/routes/api/v1/services/handlers.ts    | 254 +++++++++++++++++-
 apps/ui/src/lib/common.ts                     |  11 +
 .../components/svg/services/GlitchTip.svelte  |  51 ++++
 .../svg/services/ServiceIcons.svelte          |   2 +
 .../src/lib/components/svg/services/index.ts  |   2 +-
 .../routes/services/[id]/_ServiceLinks.svelte |   5 +-
 .../services/[id]/_Services/_GlitchTip.svelte | 208 ++++++++++++++
 .../services/[id]/_Services/_Services.svelte  |   5 +-
 13 files changed, 745 insertions(+), 5 deletions(-)
 create mode 100644 apps/api/prisma/migrations/20220815092230_glitchtip/migration.sql
 create mode 100644 apps/ui/src/lib/components/svg/services/GlitchTip.svelte
 create mode 100644 apps/ui/src/routes/services/[id]/_Services/_GlitchTip.svelte

diff --git a/README.md b/README.md
index 40d7ac2dc..e7a6175da 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,7 @@ You quickly need to host a self-hostable, open-source service? You can do it wit
 - [Umami](https://github.com/mikecao/umami)
 - [Fider](https://fider.io)
 - [Hasura](https://hasura.io)
+- [GlitchTip](https://glitchtip.com)
 
 
 If you have a new service you would like to add, raise an idea [here](https://feedback.coolify.io/) to get feedback from the community!
diff --git a/apps/api/prisma/migrations/20220815092230_glitchtip/migration.sql b/apps/api/prisma/migrations/20220815092230_glitchtip/migration.sql
new file mode 100644
index 000000000..dba98ab82
--- /dev/null
+++ b/apps/api/prisma/migrations/20220815092230_glitchtip/migration.sql
@@ -0,0 +1,30 @@
+-- CreateTable
+CREATE TABLE "GlitchTip" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "postgresqlUser" TEXT NOT NULL,
+    "postgresqlPassword" TEXT NOT NULL,
+    "postgresqlDatabase" TEXT NOT NULL,
+    "postgresqlPublicPort" INTEGER,
+    "secretKeyBase" TEXT,
+    "defaultEmail" TEXT NOT NULL,
+    "defaultUsername" TEXT NOT NULL,
+    "defaultPassword" TEXT NOT NULL,
+    "defaultEmailFrom" TEXT NOT NULL DEFAULT 'glitchtip@domain.tdl',
+    "emailSmtpHost" TEXT DEFAULT 'domain.tdl',
+    "emailSmtpPort" INTEGER DEFAULT 25,
+    "emailSmtpUser" TEXT,
+    "emailSmtpPassword" TEXT,
+    "emailSmtpUseTls" BOOLEAN DEFAULT false,
+    "emailSmtpUseSsl" BOOLEAN DEFAULT false,
+    "emailBackend" TEXT,
+    "mailgunApiKey" TEXT,
+    "sendgridApiKey" TEXT,
+    "enableOpenUserRegistration" BOOLEAN NOT NULL DEFAULT true,
+    "serviceId" TEXT NOT NULL,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL,
+    CONSTRAINT "GlitchTip_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "GlitchTip_serviceId_key" ON "GlitchTip"("serviceId");
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 7d50b63b8..555c2341b 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -325,6 +325,7 @@ model Service {
   destinationDocker   DestinationDocker?         @relation(fields: [destinationDockerId], references: [id])
   fider               Fider?
   ghost               Ghost?
+  glitchTip           GlitchTip?
   hasura              Hasura?
   meiliSearch         MeiliSearch?
   minio               Minio?
@@ -491,3 +492,30 @@ model Moodle {
   updatedAt               DateTime @updatedAt
   service                 Service  @relation(fields: [serviceId], references: [id])
 }
+
+model GlitchTip {
+  id                         String   @id @default(cuid())
+  postgresqlUser             String
+  postgresqlPassword         String
+  postgresqlDatabase         String
+  postgresqlPublicPort       Int?
+  secretKeyBase              String?
+  defaultEmail               String
+  defaultUsername            String
+  defaultPassword            String
+  defaultEmailFrom           String   @default("glitchtip@domain.tdl")
+  emailSmtpHost              String?  @default("domain.tdl")
+  emailSmtpPort              Int?     @default(25)
+  emailSmtpUser              String?
+  emailSmtpPassword          String?
+  emailSmtpUseTls            Boolean? @default(false)
+  emailSmtpUseSsl            Boolean? @default(false)
+  emailBackend               String?
+  mailgunApiKey              String?
+  sendgridApiKey             String?
+  enableOpenUserRegistration Boolean  @default(true)
+  serviceId                  String   @unique
+  createdAt                  DateTime @default(now())
+  updatedAt                  DateTime @updatedAt
+  service                    Service  @relation(fields: [serviceId], references: [id])
+}
diff --git a/apps/api/src/lib/common.ts b/apps/api/src/lib/common.ts
index ce8b8051d..2c26ab7d1 100644
--- a/apps/api/src/lib/common.ts
+++ b/apps/api/src/lib/common.ts
@@ -78,6 +78,7 @@ export const include: any = {
 	umami: true,
 	hasura: true,
 	fider: true,
+	glitchTip: true,
 };
 
 export const uniqueName = (): string => uniqueNamesGenerator(customConfig);
@@ -280,6 +281,17 @@ export const supportedServiceTypesAndVersions = [
 	//         main: 8080
 	//     }
 	// }
+	{
+		name: 'glitchTip',
+		fancyName: 'GlitchTip',
+		baseImage: 'glitchtip/glitchtip',
+		images: ['postgres:14-alpine', 'redis:7-alpine'],
+		versions: ['latest'],
+		recommendedVersion: 'latest',
+		ports: {
+			main: 8000
+		}
+	},
 ];
 
 export async function checkDoubleBranch(branch: string, projectId: number): Promise<boolean> {
@@ -1517,7 +1529,33 @@ export async function configureServiceType({
 				}
 			}
 		});
-	} else {
+	} else if (type === 'glitchTip') {
+		const defaultUsername = cuid();
+		const defaultEmail = `${defaultUsername}@example.com`;
+		const defaultPassword = encrypt(generatePassword());
+		const postgresqlUser = cuid();
+		const postgresqlPassword = encrypt(generatePassword());
+		const postgresqlDatabase = 'glitchTip';
+		const secretKeyBase = encrypt(generatePassword(64));
+
+		await prisma.service.update({
+			where: { id },
+			data: {
+				type,
+				glitchTip: {
+					create: {
+						postgresqlDatabase,
+						postgresqlUser,
+						postgresqlPassword,
+						secretKeyBase,
+						defaultEmail,
+						defaultUsername,
+						defaultPassword,
+					}
+				}
+			}
+		});
+	 } else {
 		await prisma.service.update({
 			where: { id },
 			data: {
@@ -1538,6 +1576,7 @@ export async function removeService({ id }: { id: string }): Promise<void> {
 	await prisma.minio.deleteMany({ where: { serviceId: id } });
 	await prisma.vscodeserver.deleteMany({ where: { serviceId: id } });
 	await prisma.wordpress.deleteMany({ where: { serviceId: id } });
+	await prisma.glitchTip.deleteMany({ where: { serviceId: id } });
 	await prisma.serviceSecret.deleteMany({ where: { serviceId: id } });
 
 	await prisma.service.delete({ where: { id } });
diff --git a/apps/api/src/lib/serviceFields.ts b/apps/api/src/lib/serviceFields.ts
index 89d6a9c70..a678d3057 100644
--- a/apps/api/src/lib/serviceFields.ts
+++ b/apps/api/src/lib/serviceFields.ts
@@ -476,4 +476,116 @@ export const moodle = [{
 	isNumber: false,
 	isBoolean: false,
 	isEncrypted: false
+}]
+export const glitchTip = [{
+	name: 'postgresqlUser',
+	isEditable: false,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: false
+},
+{
+	name: 'postgresqlPassword',
+	isEditable: false,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: true
+},
+{
+	name: 'postgresqlDatabase',
+	isEditable: false,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: false
+},
+{
+	name: 'postgresqlPublicPort',
+	isEditable: false,
+	isLowerCase: false,
+	isNumber: true,
+	isBoolean: false,
+	isEncrypted: false
+},
+{
+	name: 'secretKeyBase',
+	isEditable: false,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: true
+},
+{
+	name: 'defaultEmail',
+	isEditable: false,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: false
+},
+{
+	name: 'defaultUsername',
+	isEditable: false,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: false
+},
+{
+	name: 'defaultPassword',
+	isEditable: false,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: true
+},
+{
+	name: 'defaultFromEmail',
+	isEditable: true,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: false
+},
+{
+	name: 'emailUrl',
+	isEditable: true,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: false
+},
+{
+	name: 'emailBackend',
+	isEditable: true,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: false
+},
+{
+	name: 'mailgunApiKey',
+	isEditable: true,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: true
+},
+{
+	name: 'sendgridApiKey',
+	isEditable: true,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: false,
+	isEncrypted: true
+},
+{
+	name: 'enableOpenUserRegistration',
+	isEditable: true,
+	isLowerCase: false,
+	isNumber: false,
+	isBoolean: true,
+	isEncrypted: false
 }]
\ No newline at end of file
diff --git a/apps/api/src/routes/api/v1/services/handlers.ts b/apps/api/src/routes/api/v1/services/handlers.ts
index 8d07ac70b..50c6f7936 100644
--- a/apps/api/src/routes/api/v1/services/handlers.ts
+++ b/apps/api/src/routes/api/v1/services/handlers.ts
@@ -590,6 +590,9 @@ export async function startService(request: FastifyRequest<ServiceStartStop>) {
         if (type === 'moodle') {
             return await startMoodleService(request)
         }
+        if (type === 'glitchTip') {
+            return await startGlitchTipService(request)
+        }
         throw `Service type ${type} not supported.`
     } catch (error) {
         throw { status: 500, message: error?.message || error }
@@ -643,6 +646,9 @@ export async function stopService(request: FastifyRequest<ServiceStartStop>) {
         if (type === 'moodle') {
             return await stopMoodleService(request)
         }
+        if (type === 'glitchTip') {
+            return await stopGlitchTipService(request)
+        }
         throw `Service type ${type} not supported.`
     } catch (error) {
         throw { status: 500, message: error?.message || error }
@@ -1247,7 +1253,7 @@ async function startWordpressService(request: FastifyRequest<ServiceStartStop>)
 
         const { volumes, volumeMounts } = persistentVolumes(id, persistentStorage, config.wordpress)
 
-        let composeFile: ComposeFile = {
+        const composeFile: ComposeFile = {
             version: '3.8',
             services: {
                 [id]: {
@@ -2633,6 +2639,252 @@ async function stopMoodleService(request: FastifyRequest<ServiceStartStop>) {
     }
 }
 
+async function startGlitchTipService(request: FastifyRequest<ServiceStartStop>) {
+    try {
+        const { id } = request.params;
+        const teamId = request.user.teamId;
+        const service = await getServiceFromDB({ id, teamId });
+        const {
+            type,
+            version,
+            fqdn,
+            destinationDockerId,
+            destinationDocker,
+            serviceSecret,
+            persistentStorage,
+            exposePort,
+            glitchTip: {
+                postgresqlDatabase,
+                postgresqlPassword,
+                postgresqlUser,
+                secretKeyBase,
+                defaultEmail,
+                defaultUsername,
+                defaultPassword,
+                defaultFromEmail,
+                emailSmtpHost,
+                emailSmtpPort,
+                emailSmtpUser,
+                emailSmtpPassword,
+                emailSmtpUseTls,
+                emailSmtpUseSsl,
+                emailBackend,
+                mailgunApiKey,
+                sendgridApiKey,
+                enableOpenUserRegistration,
+            }
+        } = service;
+        const network = destinationDockerId && destinationDocker.network;
+        const port = getServiceMainPort('glitchTip');
+
+        const { workdir } = await createDirectories({ repository: type, buildId: id });
+        const image = getServiceImage(type);
+
+        const config = {
+            glitchTip: {
+                image: `${image}:${version}`,
+                environmentVariables: {
+                    PORT: port,
+                    GLITCHTIP_DOMAIN: fqdn,
+                    SECRET_KEY: secretKeyBase,
+                    DATABASE_URL: `postgresql://${postgresqlUser}:${postgresqlPassword}@${id}-postgresql:5432/${postgresqlDatabase}`,
+                    REDIS_URL: `redis://${id}-redis:6379/0`,
+                    DEFAULT_FROM_EMAIL: defaultFromEmail,
+                    EMAIL_HOST: emailSmtpHost,
+                    EMAIL_PORT: emailSmtpPort,
+                    EMAIL_HOST_USER: emailSmtpUser,
+                    EMAIL_HOST_PASSWORD: emailSmtpPassword,
+                    EMAIL_USE_TLS: emailSmtpUseTls,
+                    EMAIL_USE_SSL: emailSmtpUseSsl,
+                    EMAIL_BACKEND: emailBackend,
+                    MAILGUN_API_KEY: mailgunApiKey,
+                    SENDGRID_API_KEY: sendgridApiKey,
+                    ENABLE_OPEN_USER_REGISTRATION: enableOpenUserRegistration,
+                    DJANGO_SUPERUSER_EMAIL: defaultEmail,
+                    DJANGO_SUPERUSER_USERNAME: defaultUsername,
+                    DJANGO_SUPERUSER_PASSWORD: defaultPassword,
+                }
+            },
+            postgresql: {
+                image: 'postgres:14-alpine',
+                volume: `${id}-postgresql-data:/var/lib/postgresql/data`,
+                environmentVariables: {
+                    POSTGRES_USER: postgresqlUser,
+                    POSTGRES_PASSWORD: postgresqlPassword,
+                    POSTGRES_DB: postgresqlDatabase
+                }
+            },
+            redis: {
+                image: 'redis:7-alpine',
+                volume: `${id}-redis-data:/data`,
+            }
+        };
+        if (serviceSecret.length > 0) {
+            serviceSecret.forEach((secret) => {
+                config.glitchTip.environmentVariables[secret.name] = secret.value;
+            });
+        }
+        const { volumes, volumeMounts } = persistentVolumes(id, persistentStorage, config.glitchTip)
+        const composeFile: ComposeFile = {
+            version: '3.8',
+            services: {
+                [id]: {
+                    container_name: id,
+                    image: config.glitchTip.image,
+                    environment: config.glitchTip.environmentVariables,
+                    networks: [network],
+                    volumes,
+                    restart: 'always',
+                    labels: makeLabelForServices('glitchTip'),
+                    ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}),
+                    deploy: {
+                        restart_policy: {
+                            condition: 'on-failure',
+                            delay: '5s',
+                            max_attempts: 3,
+                            window: '120s'
+                        }
+                    },
+                    depends_on: [`${id}-postgresql`, `${id}-redis`]
+                },
+                [`${id}-worker`]: {
+                    container_name: `${id}-worker`,
+                    image: config.glitchTip.image,
+                    command: './bin/run-celery-with-beat.sh',
+                    environment: config.glitchTip.environmentVariables,
+                    networks: [network],
+                    restart: 'always',
+                    deploy: {
+                        restart_policy: {
+                            condition: 'on-failure',
+                            delay: '5s',
+                            max_attempts: 3,
+                            window: '120s'
+                        }
+                    },
+                    depends_on: [`${id}-postgresql`, `${id}-redis`]
+                },
+                [`${id}-setup`]: {
+                    container_name: `${id}-setup`,
+                    image: config.glitchTip.image,
+                    command: 'sh -c "(./manage.py migrate || true) && (./manage.py createsuperuser --noinput || true)"',
+                    environment: config.glitchTip.environmentVariables,
+                    networks: [network],
+                    restart: "no",
+                    depends_on: [`${id}-postgresql`, `${id}-redis`]
+                },
+                [`${id}-postgresql`]: {
+                    image: config.postgresql.image,
+                    container_name: `${id}-postgresql`,
+                    environment: config.postgresql.environmentVariables,
+                    networks: [network],
+                    volumes: [config.postgresql.volume],
+                    restart: 'always',
+                    deploy: {
+                        restart_policy: {
+                            condition: 'on-failure',
+                            delay: '5s',
+                            max_attempts: 3,
+                            window: '120s'
+                        }
+                    }
+                },
+                [`${id}-redis`]: {
+                    image: config.redis.image,
+                    container_name: `${id}-redis`,
+                    networks: [network],
+                    volumes: [config.redis.volume],
+                    restart: 'always',
+                    deploy: {
+                        restart_policy: {
+                            condition: 'on-failure',
+                            delay: '5s',
+                            max_attempts: 3,
+                            window: '120s'
+                        }
+                    }
+                }
+            },
+            networks: {
+                [network]: {
+                    external: true
+                }
+            },
+            volumes: {
+                ...volumeMounts,
+                [config.postgresql.volume.split(':')[0]]: {
+                    name: config.postgresql.volume.split(':')[0]
+                },
+                [config.redis.volume.split(':')[0]]: {
+                    name: config.redis.volume.split(':')[0]
+                }
+            }
+        };
+        const composeFileDestination = `${workdir}/docker-compose.yaml`;
+        await fs.writeFile(composeFileDestination, yaml.dump(composeFile));
+
+        await executeDockerCmd({ dockerId: destinationDocker.id, command: `docker compose -f ${composeFileDestination} pull` })
+        await executeDockerCmd({ dockerId: destinationDocker.id, command: `docker compose -f ${composeFileDestination} up --build -d` })
+
+        return {}
+    } catch ({ status, message }) {
+        return errorHandler({ status, message })
+    }
+}
+async function stopGlitchTipService(request: FastifyRequest<ServiceStartStop>) {
+    try {
+        const { id } = request.params;
+        const teamId = request.user.teamId;
+        const service = await getServiceFromDB({ id, teamId });
+        const { destinationDockerId, destinationDocker } = service;
+        if (destinationDockerId) {
+            try {
+                const found = await checkContainer({ dockerId: destinationDocker.id, container: id });
+                if (found) {
+                    await removeContainer({ id, dockerId: destinationDocker.id });
+                }
+            } catch (error) {
+                console.error(error);
+            }
+            try {
+                const found = await checkContainer({ dockerId: destinationDocker.id, container: `${id}-worker` });
+                if (found) {
+                    await removeContainer({ id: `${id}-worker`, dockerId: destinationDocker.id });
+                }
+            } catch (error) {
+                console.error(error);
+            }
+            try {
+                const found = await checkContainer({ dockerId: destinationDocker.id, container: `${id}-setup` });
+                if (found) {
+                    await removeContainer({ id: `${id}-setup`, dockerId: destinationDocker.id });
+                }
+            } catch (error) {
+                console.error(error);
+            }
+            try {
+                const found = await checkContainer({ dockerId: destinationDocker.id, container: `${id}-postgresql` });
+                if (found) {
+                    await removeContainer({ id: `${id}-postgresql`, dockerId: destinationDocker.id });
+                }
+            } catch (error) {
+                console.error(error);
+            }
+            try {
+                const found = await checkContainer({ dockerId: destinationDocker.id, container: `${id}-redis` });
+                if (found) {
+                    await removeContainer({ id: `${id}-redis`, dockerId: destinationDocker.id });
+                }
+            } catch (error) {
+                console.error(error);
+            }
+        }
+        return {}
+    } catch ({ status, message }) {
+        return errorHandler({ status, message })
+    }
+}
+
 
 export async function activatePlausibleUsers(request: FastifyRequest<OnlyId>, reply: FastifyReply) {
     try {
diff --git a/apps/ui/src/lib/common.ts b/apps/ui/src/lib/common.ts
index 79c1598b5..3eeffc1db 100644
--- a/apps/ui/src/lib/common.ts
+++ b/apps/ui/src/lib/common.ts
@@ -159,6 +159,17 @@ export const supportedServiceTypesAndVersions = [
 	//         main: 8080
 	//     }
 	// }
+	{
+		name: 'glitchTip',
+		fancyName: 'GlitchTip',
+		baseImage: 'glitchtip/glitchtip',
+		images: ['postgres:14-alpine', 'redis:7-alpine'],
+		versions: ['latest'],
+		recommendedVersion: 'latest',
+		ports: {
+			main: 8000
+		}
+	},
 ];
 
 export const asyncSleep = (delay: number) =>
diff --git a/apps/ui/src/lib/components/svg/services/GlitchTip.svelte b/apps/ui/src/lib/components/svg/services/GlitchTip.svelte
new file mode 100644
index 000000000..f61a87bbd
--- /dev/null
+++ b/apps/ui/src/lib/components/svg/services/GlitchTip.svelte
@@ -0,0 +1,51 @@
+<script lang="ts">
+	export let isAbsolute = false;
+</script>
+
+<svg
+  class={isAbsolute ? 'w-10 h-10 absolute top-0 left-0 -m-5' : 'w-8 mx-auto'}
+  xmlns="http://www.w3.org/2000/svg"
+  xmlns:xlink="http://www.w3.org/1999/xlink"
+  style="isolation:isolate"
+  viewBox="0 0 400 400"
+>
+  <defs>
+    <clipPath id="_clipPath_5kOQy2sGcuF9aeG3NHWmCAGgMEPQrnNW">
+      <rect width="400" height="400" />
+    </clipPath>
+  </defs>
+  <g clip-path="url(#_clipPath_5kOQy2sGcuF9aeG3NHWmCAGgMEPQrnNW)">
+    <g>
+      <g>
+        <path
+          d=" M 276.155 367.684 L 337.655 367.684 L 337.655 180.781 L 205.525 180.781 L 205.525 241.801 L 267.987 241.801 L 267.987 258.617 C 267.987 291.29 238.678 308.586 202.162 308.586 C 156.998 308.586 127.689 282.641 127.689 226.906 L 127.689 173.094 C 127.689 117.359 156.998 91.414 202.162 91.414 C 241.08 91.414 261.74 112.554 271.83 138.5 L 331.409 104.386 C 306.424 52.976 261.74 26.55 202.162 26.55 C 111.353 26.55 50.333 88.531 50.333 201.441 C 50.333 313.872 110.873 373.45 187.748 373.45 C 238.197 373.45 268.947 347.985 273.752 314.352 L 276.155 314.352 L 276.155 367.684 Z "
+          fill="rgb(132,24,128)"
+        />
+      </g>
+      <g opacity="0.5">
+        <path
+          d=" M 139.701 175.78 L 139.701 173.094 C 139.701 117.359 169.01 91.414 214.174 91.414 C 253.092 91.414 273.752 112.554 283.842 138.5 L 343.421 104.386 C 318.436 52.976 273.752 26.55 214.174 26.55 C 128.962 26.55 69.981 81.125 63.033 181.145 L 139.701 175.78 Z "
+          fill-rule="evenodd"
+          fill="rgb(233,64,86)"
+        />
+      </g>
+      <g opacity="0.5">
+        <path
+          d=" M 349.667 305.194 L 349.667 247.137 L 279.998 252.019 L 279.998 258.617 C 279.998 291.29 250.69 308.586 214.174 308.586 C 179.697 308.586 154.459 293.467 144.446 261.518 L 70.341 266.711 C 76.285 288.796 85.348 307.563 96.86 322.909 L 349.667 305.194 Z "
+          fill-rule="evenodd"
+          fill="rgb(233,64,86)"
+        />
+      </g>
+      <path
+        d=" M 337.655 247.03 L 337.655 180.781 L 205.525 180.781 L 205.525 241.801 L 267.987 241.801 L 267.987 251.912 L 337.655 247.03 Z  M 132.401 261.413 C 129.319 251.534 127.689 240.048 127.689 226.906 L 127.689 175.099 L 51.069 180.468 C 50.581 187.25 50.333 194.242 50.333 201.441 C 50.333 225.632 53.136 247.376 58.301 266.606 L 132.401 261.413 Z "
+        fill-rule="evenodd"
+        fill="rgb(233,64,86)"
+      />
+      <path
+        d=" M 337.655 305.862 L 337.655 367.684 L 276.155 367.684 L 276.155 314.352 L 273.752 314.352 C 268.947 347.985 238.197 373.45 187.748 373.45 C 146.712 373.45 110.33 356.473 85.327 323.543 L 337.655 305.862 Z "
+        fill-rule="evenodd"
+        fill="rgb(255,63,42)"
+      />
+    </g>
+  </g>
+</svg>
\ No newline at end of file
diff --git a/apps/ui/src/lib/components/svg/services/ServiceIcons.svelte b/apps/ui/src/lib/components/svg/services/ServiceIcons.svelte
index 00e1bcf6e..856d7f785 100644
--- a/apps/ui/src/lib/components/svg/services/ServiceIcons.svelte
+++ b/apps/ui/src/lib/components/svg/services/ServiceIcons.svelte
@@ -34,4 +34,6 @@
 	<Icons.Fider {isAbsolute} />
 {:else if type === 'moodle'}
 	<Icons.Moodle {isAbsolute} />
+{:else if type === 'glitchTip'}
+	<Icons.GlitchTip {isAbsolute} />
 {/if}
diff --git a/apps/ui/src/lib/components/svg/services/index.ts b/apps/ui/src/lib/components/svg/services/index.ts
index 007ded1ce..08b56de4c 100644
--- a/apps/ui/src/lib/components/svg/services/index.ts
+++ b/apps/ui/src/lib/components/svg/services/index.ts
@@ -14,4 +14,4 @@ export { default as Umami }  from './Umami.svelte';
 export { default as Hasura }  from './Hasura.svelte';
 export { default as Fider }  from './Fider.svelte';
 export { default as Moodle }  from './Moodle.svelte';
-
+export { default as GlitchTip }  from './GlitchTip.svelte';
diff --git a/apps/ui/src/routes/services/[id]/_ServiceLinks.svelte b/apps/ui/src/routes/services/[id]/_ServiceLinks.svelte
index e25f3d2d0..9c1b88544 100644
--- a/apps/ui/src/routes/services/[id]/_ServiceLinks.svelte
+++ b/apps/ui/src/routes/services/[id]/_ServiceLinks.svelte
@@ -59,5 +59,8 @@
 	<a href="https://moodle.org" target="_blank">
 		<Icons.Moodle />
 	</a>
+{:else if service.type === 'glitchTip'}
+	<a href="https://glitchtip.com" target="_blank">
+		<Icons.GlitchTip />
+	</a>
 {/if}
-
diff --git a/apps/ui/src/routes/services/[id]/_Services/_GlitchTip.svelte b/apps/ui/src/routes/services/[id]/_Services/_GlitchTip.svelte
new file mode 100644
index 000000000..03fcc3ef4
--- /dev/null
+++ b/apps/ui/src/routes/services/[id]/_Services/_GlitchTip.svelte
@@ -0,0 +1,208 @@
+<script lang="ts">
+	import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
+	import Explainer from '$lib/components/Explainer.svelte';
+	import Setting from '$lib/components/Setting.svelte';
+	import { t } from '$lib/translations';
+	export let service: any;
+	function toggleEmailSmtpUseTls() {
+		service.glitchTip.emailSmtpUseTls = !service.glitchTip.emailSmtpUseTls;
+	}
+	function toggleEmailSmtpUseSsl() {
+		service.glitchTip.emailSmtpUseSsl = !service.glitchTip.emailSmtpUseSsl;
+	}
+	function toggleEnableOpenUserRegistration() {
+		service.glitchTip.enableOpenUserRegistration = !service.glitchTip.enableOpenUserRegistration;
+	}
+</script>
+
+<div class="flex space-x-1 py-5 font-bold">
+	<div class="title">GlitchTip</div>
+</div>
+
+<div class="flex space-x-1 py-2 font-bold">
+	<div class="subtitle">Settings</div>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<Setting
+		bind:setting={service.glitchTip.enableOpenUserRegistration}
+		on:click={toggleEnableOpenUserRegistration}
+		title={'Enable Open User Registration'}
+		description={''}
+	/>
+</div>
+
+<div class="flex space-x-1 py-2 font-bold">
+	<div class="subtitle">Email settings</div>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="defaultEmailFrom" class="text-base font-bold text-stone-100">Default Email From</label
+	>
+	<CopyPasswordField
+		required
+		name="defaultEmailFrom"
+		id="defaultEmailFrom"
+		value={service.glitchTip.defaultEmailFrom}
+	/>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="emailSmtpHost" class="text-base font-bold text-stone-100">SMTP Host</label>
+	<CopyPasswordField
+		name="emailSmtpHost"
+		id="emailSmtpHost"
+		value={service.glitchTip.emailSmtpHost}
+	/>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="emailSmtpPort" class="text-base font-bold text-stone-100">SMTP Port</label>
+	<CopyPasswordField
+		name="emailSmtpPort"
+		id="emailSmtpPort"
+		value={service.glitchTip.emailSmtpPort}
+	/>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="emailSmtpUser" class="text-base font-bold text-stone-100">SMTP User</label>
+	<CopyPasswordField
+		name="emailSmtpUser"
+		id="emailSmtpUser"
+		value={service.glitchTip.emailSmtpUser}
+	/>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="emailSmtpPassword" class="text-base font-bold text-stone-100">SMTP Password</label>
+	<CopyPasswordField
+		name="emailSmtpPassword"
+		id="emailSmtpPassword"
+		value={service.glitchTip.emailSmtpPassword}
+		isPasswordField
+	/>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<Setting
+		bind:setting={service.glitchTip.emailSmtpUseTls}
+		on:click={toggleEmailSmtpUseTls}
+		title={'SMTP Use TLS'}
+		description={''}
+	/>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<Setting
+		bind:setting={service.glitchTip.emailSmtpUseSsl}
+		on:click={toggleEmailSmtpUseSsl}
+		title={'SMTP Use SSL'}
+		description={''}
+	/>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="emailBackend" class="text-base font-bold text-stone-100">Email Backend</label>
+	<CopyPasswordField name="emailBackend" id="emailBackend" value={service.glitchTip.emailBackend} />
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="mailgunApiKey" class="text-base font-bold text-stone-100">Mailgun API Key</label>
+	<CopyPasswordField
+		name="mailgunApiKey"
+		id="mailgunApiKey"
+		value={service.glitchTip.mailgunApiKey}
+	/>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="sendgridApiKey" class="text-base font-bold text-stone-100">SendGrid API Key</label>
+	<CopyPasswordField
+		name="sendgridApiKey"
+		id="sendgridApiKey"
+		value={service.glitchTip.sendgridApiKey}
+	/>
+</div>
+
+<div class="flex space-x-1 py-2 font-bold">
+	<div class="subtitle">Default User & Superuser</div>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="defaultEmail" class="text-base font-bold text-stone-100">{$t('forms.email')}</label>
+	<CopyPasswordField
+		name="defaultEmail"
+		id="defaultEmail"
+		value={service.glitchTip.defaultEmail}
+		readonly
+		disabled
+	/>
+</div>
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="defaultUsername" class="text-base font-bold text-stone-100"
+		>{$t('forms.username')}</label
+	>
+	<CopyPasswordField
+		name="defaultUsername"
+		id="defaultUsername"
+		value={service.glitchTip.defaultUsername}
+		readonly
+		disabled
+	/>
+</div>
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="defaultPassword" class="text-base font-bold text-stone-100"
+		>{$t('forms.password')}</label
+	>
+	<CopyPasswordField
+		name="defaultPassword"
+		id="defaultPassword"
+		value={service.glitchTip.defaultPassword}
+		readonly
+		disabled
+		isPasswordField
+	/>
+</div>
+
+<div class="flex space-x-1 py-5 font-bold">
+	<div class="title">PostgreSQL</div>
+</div>
+
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="postgresqlUser" class="text-base font-bold text-stone-100"
+		>{$t('forms.username')}</label
+	>
+	<CopyPasswordField
+		name="postgresqlUser"
+		id="postgresqlUser"
+		value={service.glitchTip.postgresqlUser}
+		readonly
+		disabled
+	/>
+</div>
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="postgresqlPassword" class="text-base font-bold text-stone-100"
+		>{$t('forms.password')}</label
+	>
+	<CopyPasswordField
+		id="postgresqlPassword"
+		isPasswordField
+		readonly
+		disabled
+		name="postgresqlPassword"
+		value={service.glitchTip.postgresqlPassword}
+	/>
+</div>
+<div class="grid grid-cols-2 items-center px-10">
+	<label for="postgresqlDatabase" class="text-base font-bold text-stone-100"
+		>{$t('index.database')}</label
+	>
+	<CopyPasswordField
+		name="postgresqlDatabase"
+		id="postgresqlDatabase"
+		value={service.glitchTip.postgresqlDatabase}
+		readonly
+		disabled
+	/>
+</div>
diff --git a/apps/ui/src/routes/services/[id]/_Services/_Services.svelte b/apps/ui/src/routes/services/[id]/_Services/_Services.svelte
index 3873604d1..56f1ebef5 100644
--- a/apps/ui/src/routes/services/[id]/_Services/_Services.svelte
+++ b/apps/ui/src/routes/services/[id]/_Services/_Services.svelte
@@ -19,6 +19,7 @@
 
 	import Fider from './_Fider.svelte';
 	import Ghost from './_Ghost.svelte';
+	import GlitchTip from './_GlitchTip.svelte';
 	import Hasura from './_Hasura.svelte';
 	import MeiliSearch from './_MeiliSearch.svelte';
 	import MinIo from './_MinIO.svelte';
@@ -37,7 +38,7 @@
 		save: false,
 		verification: false,
 		cleanup: false
-	}
+	};
 	let dualCerts = service.dualCerts;
 
 	let nonWWWDomain = service.fqdn && getDomain(service.fqdn).replace(/^www\./, '');
@@ -396,6 +397,8 @@
 				<Fider bind:service {readOnly} />
 			{:else if service.type === 'moodle'}
 				<Moodle bind:service {readOnly} />
+			{:else if service.type === 'glitchTip'}
+				<GlitchTip bind:service />
 			{/if}
 		</div>
 	</form>