diff --git a/db/knex_init_db.js b/db/knex_init_db.js
index fbf8d2b9..c5ea0397 100644
--- a/db/knex_init_db.js
+++ b/db/knex_init_db.js
@@ -11,7 +11,6 @@ async function createTables() {
     log.info("mariadb", "Creating basic tables for MariaDB");
     const knex = R.knex;
 
-    // Up to `patch-add-google-analytics-status-page-tag.sql` for now
     // TODO: Should check later if it is really the final patch sql file.
 
     // docker_host
diff --git a/db/knex_migrations/2023-06-30-1348-http-body-encoding.js b/db/knex_migrations/2023-06-30-1348-http-body-encoding.js
new file mode 100644
index 00000000..c4cc7d94
--- /dev/null
+++ b/db/knex_migrations/2023-06-30-1348-http-body-encoding.js
@@ -0,0 +1,22 @@
+// ALTER TABLE monitor ADD http_body_encoding VARCHAR(25);
+// UPDATE monitor SET http_body_encoding = 'json' WHERE (type = 'http' or type = 'keyword') AND http_body_encoding IS NULL;
+exports.up = function (knex) {
+    return knex.schema.table("monitor", function (table) {
+        table.string("http_body_encoding", 25);
+    }).then(function () {
+        knex("monitor")
+            .where(function () {
+                this.where("type", "http").orWhere("type", "keyword");
+            })
+            .whereNull("http_body_encoding")
+            .update({
+                http_body_encoding: "json",
+            });
+    });
+};
+
+exports.down = function (knex) {
+    return knex.schema.table("monitor", function (table) {
+        table.dropColumn("http_body_encoding");
+    });
+};
diff --git a/db/knex_migrations/2023-06-30-1354-add-description-monitor.js b/db/knex_migrations/2023-06-30-1354-add-description-monitor.js
new file mode 100644
index 00000000..4b291777
--- /dev/null
+++ b/db/knex_migrations/2023-06-30-1354-add-description-monitor.js
@@ -0,0 +1,12 @@
+// ALTER TABLE monitor ADD description TEXT default null;
+exports.up = function (knex) {
+    return knex.schema.table("monitor", function (table) {
+        table.text("description").defaultTo(null);
+    });
+};
+
+exports.down = function (knex) {
+    return knex.schema.table("monitor", function (table) {
+        table.dropColumn("description");
+    });
+};
diff --git a/db/knex_migrations/2023-06-30-1357-api-key-table.js b/db/knex_migrations/2023-06-30-1357-api-key-table.js
new file mode 100644
index 00000000..d22721ed
--- /dev/null
+++ b/db/knex_migrations/2023-06-30-1357-api-key-table.js
@@ -0,0 +1,30 @@
+/*
+CREATE TABLE [api_key] (
+    [id] INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+    [key] VARCHAR(255) NOT NULL,
+    [name] VARCHAR(255) NOT NULL,
+    [user_id] INTEGER NOT NULL,
+    [created_date] DATETIME DEFAULT (DATETIME('now')) NOT NULL,
+    [active] BOOLEAN DEFAULT 1 NOT NULL,
+    [expires] DATETIME DEFAULT NULL,
+    CONSTRAINT FK_user FOREIGN KEY ([user_id]) REFERENCES [user]([id]) ON DELETE CASCADE ON UPDATE CASCADE
+);
+ */
+exports.up = function (knex) {
+    return knex.schema.createTable("api_key", function (table) {
+        table.increments("id").primary();
+        table.string("key", 255).notNullable();
+        table.string("name", 255).notNullable();
+        table.integer("user_id").unsigned().notNullable()
+            .references("id").inTable("user")
+            .onDelete("CASCADE")
+            .onUpdate("CASCADE");
+        table.dateTime("created_date").defaultTo(knex.fn.now()).notNullable();
+        table.boolean("active").defaultTo(1).notNullable();
+        table.dateTime("expires").defaultTo(null);
+    });
+};
+
+exports.down = function (knex) {
+    return knex.schema.dropTable("api_key");
+};
diff --git a/db/knex_migrations/2023-06-30-1400-monitor-tls.js b/db/knex_migrations/2023-06-30-1400-monitor-tls.js
new file mode 100644
index 00000000..95d66bab
--- /dev/null
+++ b/db/knex_migrations/2023-06-30-1400-monitor-tls.js
@@ -0,0 +1,25 @@
+/*
+ALTER TABLE monitor
+    ADD tls_ca TEXT default null;
+
+ALTER TABLE monitor
+    ADD tls_cert TEXT default null;
+
+ALTER TABLE monitor
+    ADD tls_key TEXT default null;
+ */
+exports.up = function (knex) {
+    return knex.schema.table("monitor", function (table) {
+        table.text("tls_ca").defaultTo(null);
+        table.text("tls_cert").defaultTo(null);
+        table.text("tls_key").defaultTo(null);
+    });
+};
+
+exports.down = function (knex) {
+    return knex.schema.table("monitor", function (table) {
+        table.dropColumn("tls_ca");
+        table.dropColumn("tls_cert");
+        table.dropColumn("tls_key");
+    });
+};
diff --git a/db/knex_migrations/2023-06-30-1401-maintenance-cron.js b/db/knex_migrations/2023-06-30-1401-maintenance-cron.js
new file mode 100644
index 00000000..51ae7a9b
--- /dev/null
+++ b/db/knex_migrations/2023-06-30-1401-maintenance-cron.js
@@ -0,0 +1,25 @@
+/*
+-- 999 characters. https://stackoverflow.com/questions/46134830/maximum-length-for-cron-job
+DROP TABLE maintenance_timeslot;
+ALTER TABLE maintenance ADD cron TEXT;
+ALTER TABLE maintenance ADD timezone VARCHAR(255);
+ALTER TABLE maintenance ADD duration INTEGER;
+ */
+exports.up = function (knex) {
+    return knex.schema
+        .dropTableIfExists("maintenance_timeslot")
+        .table("maintenance", function (table) {
+            table.text("cron");
+            table.string("timezone", 255);
+            table.integer("duration");
+        });
+};
+
+exports.down = function (knex) {
+    return knex.schema
+        .table("maintenance", function (table) {
+            table.dropColumn("cron");
+            table.dropColumn("timezone");
+            table.dropColumn("duration");
+        });
+};
diff --git a/db/knex_migrations/2023-06-30-1413-add-parent-monitor.js b/db/knex_migrations/2023-06-30-1413-add-parent-monitor.js
new file mode 100644
index 00000000..2d417b8c
--- /dev/null
+++ b/db/knex_migrations/2023-06-30-1413-add-parent-monitor.js
@@ -0,0 +1,18 @@
+/*
+ALTER TABLE monitor
+    ADD parent INTEGER REFERENCES [monitor] ([id]) ON DELETE SET NULL ON UPDATE CASCADE;
+ */
+exports.up = function (knex) {
+    return knex.schema.table("monitor", function (table) {
+        table.integer("parent").unsigned()
+            .references("id").inTable("monitor")
+            .onDelete("SET NULL")
+            .onUpdate("CASCADE");
+    });
+};
+
+exports.down = function (knex) {
+    return knex.schema.table("monitor", function (table) {
+        table.dropColumn("parent");
+    });
+};
diff --git a/db/knex_migrations/README.md b/db/knex_migrations/README.md
index bcad0468..5789307b 100644
--- a/db/knex_migrations/README.md
+++ b/db/knex_migrations/README.md
@@ -21,7 +21,9 @@ exports.down = function(knex) {
 
 ## Example
 
-20230211120000_create_users_products.js
+YYYY-MM-DD-HHMM-create-users-products.js
+
+2023-06-30-1348-create-users-products.js
 
 ```js
 exports.up = function(knex) {
@@ -44,3 +46,5 @@ exports.down = function(knex) {
       .dropTable("users");
 };
 ```
+
+https://knexjs.org/guide/migrations.html#transactions-in-migrations
diff --git a/docker/debian-base.dockerfile b/docker/debian-base.dockerfile
index 003e3f9e..cde5cd2f 100644
--- a/docker/debian-base.dockerfile
+++ b/docker/debian-base.dockerfile
@@ -1,36 +1,33 @@
-# DON'T UPDATE TO node:14-bullseye-slim, see #372.
 # If the image changed, the second stage image should be changed too
-FROM node:18-buster-slim AS base2-slim
+FROM node:18-bullseye-slim AS base2-slim
 ARG TARGETPLATFORM
 
-# Install Curl
-# Install Apprise, add sqlite3 cli for debugging in the future, iputils-ping for ping, util-linux for setpriv
-# Stupid python3 and python3-pip actually install a lot of useless things into Debian, specify --no-install-recommends to skip them, make the base even smaller than alpine!
-RUN apt-get update && \
-    apt-get --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
+RUN apt update && \
+    apt --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
         sqlite3 iputils-ping util-linux dumb-init git curl ca-certificates && \
     pip3 --no-cache-dir install apprise==1.4.0 && \
     rm -rf /var/lib/apt/lists/* && \
     apt --yes autoremove
 
 # Install cloudflared
-RUN set -eux && \
-    mkdir -p --mode=0755 /usr/share/keyrings && \
-    curl --fail --show-error --silent --location --insecure https://pkg.cloudflare.com/cloudflare-main.gpg --output /usr/share/keyrings/cloudflare-main.gpg && \
-    echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared buster main' | tee /etc/apt/sources.list.d/cloudflared.list && \
-    apt-get update && \
-    apt-get install --yes --no-install-recommends cloudflared && \
+RUN curl https://pkg.cloudflare.com/cloudflare-main.gpg --output /usr/share/keyrings/cloudflare-main.gpg && \
+    echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bullseye main' | tee /etc/apt/sources.list.d/cloudflared.list && \
+    apt update && \
+    apt install --yes --no-install-recommends cloudflared && \
     cloudflared version && \
     rm -rf /var/lib/apt/lists/* && \
     apt --yes autoremove
 
+# Full Base Image
+# MariaDB, Chromium and fonts
+# Not working for armv7, so use the older version (10.5) of MariaDB from the debian repo
+# curl -LsS https://r.mariadb.com/downloads/mariadb_repo_setup | bash -s -- --mariadb-server-version="mariadb-11.1" && \
 FROM base2-slim AS base2
 RUN apt update && \
-    apt --yes --no-install-recommends install curl && \
-    curl -LsS https://r.mariadb.com/downloads/mariadb_repo_setup | bash -s -- --mariadb-server-version="mariadb-10.11" && \
-    apt --yes --no-install-recommends install mariadb-server && \
+    apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk mariadb-server && \
     apt --yes remove curl && \
     rm -rf /var/lib/apt/lists/* && \
-    apt --yes autoremove
-RUN chown -R node:node /var/lib/mysql
+    apt --yes autoremove && \
+    chown -R node:node /var/lib/mysql
+
 ENV UPTIME_KUMA_ENABLE_EMBEDDED_MARIADB=1
diff --git a/server/database.js b/server/database.js
index e362128f..6c5d71ee 100644
--- a/server/database.js
+++ b/server/database.js
@@ -76,7 +76,7 @@ class Database {
         "patch-api-key-table.sql": true,
         "patch-monitor-tls.sql": true,
         "patch-maintenance-cron.sql": true,
-        "patch-add-parent-monitor.sql": true,
+        "patch-add-parent-monitor.sql": true,   // The last file so far converted to a knex migration file
     };
 
     /**
@@ -305,16 +305,30 @@ class Database {
     }
 
     static async patch() {
+        // Still need to keep this for old versions of Uptime Kuma
         if (Database.dbConfig.type === "sqlite") {
             await this.patchSqlite();
         }
 
-        // TODO: Using knex migrations
+        // Using knex migrations
         // https://knexjs.org/guide/migrations.html
         // https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261
-        await R.knex.migrate.latest({
-            directory: Database.knexMigrationsPath,
-        });
+        try {
+            await R.knex.migrate.latest({
+                directory: Database.knexMigrationsPath,
+            });
+        } catch (e) {
+            log.error("db", "Database migration failed");
+            throw e;
+        }
+    }
+
+    /**
+     *
+     * @returns {Promise<void>}
+     */
+    static async rollbackLatestPatch() {
+
     }
 
     /**