Repository: SillyTavern/SillyTavern (language: javascript)

Issue:
[FEATURE_REQUEST] Allow read-only install
### Have you searched for similar requests?

Yes

### Is your feature request related to a problem? If so, please describe.

I want to package sillytavern for nixpkgs (https://github.com/NixOS/nixpkgs/pull/370468). However, currently sillytavern suppose users run it in repo directory (for example, sillytavern copy default content from cwd) and suppose repo directory is writable (for example, when generating new config.yaml).

Allowing read-only install will make packaging sillytavern for linux distribution easier.

### Describe the solution you'd like

Make some code change to ensure sillytavern can be runned outside repo directory with read-only installation. I can help about that.

### Describe alternatives you've considered

_No response_

### Additional context

_No response_

### Priority

Low (Nice-to-have)

### Are you willing to test this on staging/unstable branch if this is implemented?

Yes


Discussion / hints:

Fix patch:
diff --git a/.dockerignore b/.dockerignore
index 9e0a20629a..7008980d78 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -13,3 +13,4 @@ access.log
 /cache
 .DS_Store
 /public/scripts/extensions/third-party
+/colab
diff --git a/.github/readme.md b/.github/readme.md
index fc8cef288d..dd68b3b313 100644
--- a/.github/readme.md
+++ b/.github/readme.md
@@ -350,6 +350,7 @@ Start.bat --port 8000 --listen false
 | Option                  | Description                                                          | Type     |
 |-------------------------|----------------------------------------------------------------------|----------|
 | `--version`             | Show version number                                                  | boolean  |
+| `--configPath`          | Override the path to the config.yaml file                            | string   |
 | `--dataRoot`            | Root directory for data storage                                      | string   |
 | `--port`                | Sets the port under which SillyTavern will run                       | number   |
 | `--listen`              | SillyTavern will listen on all network interfaces                    | boolean  |
diff --git a/.npmignore b/.npmignore
index 0c99b6680c..7b9e618590 100644
--- a/.npmignore
+++ b/.npmignore
@@ -12,3 +12,4 @@ access.log
 .vscode
 .git
 /public/scripts/extensions/third-party
+/colab
diff --git a/Dockerfile b/Dockerfile
index a133d0a477..4a90d5c905 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,15 +12,13 @@ WORKDIR ${APP_HOME}
 # Set NODE_ENV to production
 ENV NODE_ENV=production
 
-# Install app dependencies
-COPY package*.json post-install.js ./
+# Bundle app source
+COPY . ./
+
 RUN \
   echo "*** Install npm packages ***" && \
   npm i --no-audit --no-fund --loglevel=error --no-progress --omit=dev && npm cache clean --force
 
-# Bundle app source
-COPY . ./
-
 # Copy default chats, characters and user avatars to <folder>.default folder
 RUN \
   rm -f "config.yaml" || true && \
diff --git a/post-install.js b/post-install.js
index ae01b7d1ab..4908e950ec 100644
--- a/post-install.js
+++ b/post-install.js
@@ -3,133 +3,17 @@
  */
 import fs from 'node:fs';
 import path from 'node:path';
-import crypto from 'node:crypto';
 import process from 'node:process';
 import yaml from 'yaml';
-import _ from 'lodash';
 import chalk from 'chalk';
 import { createRequire } from 'node:module';
+import { addMissingConfigValues } from './src/config-init.js';
 
 /**
  * Colorizes console output.
  */
 const color = chalk;
 
-const keyMigrationMap = [
-    {
-        oldKey: 'disableThumbnails',
-        newKey: 'thumbnails.enabled',
-        migrate: (value) => !value,
-    },
-    {
-        oldKey: 'thumbnailsQuality',
-        newKey: 'thumbnails.quality',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'avatarThumbnailsPng',
-        newKey: 'thumbnails.format',
-        migrate: (value) => (value ? 'png' : 'jpg'),
-    },
-    {
-        oldKey: 'disableChatBackup',
-        newKey: 'backups.chat.enabled',
-        migrate: (value) => !value,
-    },
-    {
-        oldKey: 'numberOfBackups',
-        newKey: 'backups.common.numberOfBackups',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'maxTotalChatBackups',
-        newKey: 'backups.chat.maxTotalBackups',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'chatBackupThrottleInterval',
-        newKey: 'backups.chat.throttleInterval',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'enableExtensions',
-        newKey: 'extensions.enabled',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'enableExtensionsAutoUpdate',
-        newKey: 'extensions.autoUpdate',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'extras.disableAutoDownload',
-        newKey: 'extensions.models.autoDownload',
-        migrate: (value) => !value,
-    },
-    {
-        oldKey: 'extras.classificationModel',
-        newKey: 'extensions.models.classification',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'extras.captioningModel',
-        newKey: 'extensions.models.captioning',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'extras.embeddingModel',
-        newKey: 'extensions.models.embedding',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'extras.speechToTextModel',
-        newKey: 'extensions.models.speechToText',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'extras.textToSpeechModel',
-        newKey: 'extensions.models.textToSpeech',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'minLogLevel',
-        newKey: 'logging.minLogLevel',
-        migrate: (value) => value,
-    },
-    {
-        oldKey: 'cardsCacheCapacity',
-        newKey: 'performance.memoryCacheCapacity',
-        migrate: (value) => `${value}mb`,
-    },
-    {
-        oldKey: 'cookieSecret',
-        newKey: 'cookieSecret',
-        migrate: () => void 0,
-        remove: true,
-    },
-];
-
-/**
- * Gets all keys from an object recursively.
- * @param {object} obj Object to get all keys from
- * @param {string} prefix Prefix to prepend to all keys
- * @returns {string[]} Array of all keys in the object
- */
-function getAllKeys(obj, prefix = '') {
-    if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) {
-        return [];
-    }
-
-    return _.flatMap(Object.keys(obj), key => {
-        const newPrefix = prefix ? `${prefix}.${key}` : key;
-        if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
-            return getAllKeys(obj[key], newPrefix);
-        } else {
-            return [newPrefix];
-        }
-    });
-}
-
 /**
  * Converts the old config.conf file to the new config.yaml format.
  */
@@ -156,71 +40,6 @@ function convertConfig() {
     }
 }
 
-/**
- * Compares the current config.yaml with the default config.yaml and adds any missing values.
- */
-function addMissingConfigValues() {
-    try {
-        const defaultConfig = yaml.parse(fs.readFileSync(path.join(process.cwd(), './default/config.yaml'), 'utf8'));
-        let config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
-
-        // Migrate old keys to new keys
-        const migratedKeys = [];
-        for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) {
-            if (_.has(config, oldKey)) {
-                if (remove) {
-                    _.unset(config, oldKey);
-                    migratedKeys.push({
-                        oldKey,
-                        newValue: void 0,
-                    });
-                    continue;
-                }
-
-                const oldValue = _.get(config, oldKey);
-                const newValue = migrate(oldValue);
-                _.set(config, newKey, newValue);
-                _.unset(config, oldKey);
-
-                migratedKeys.push({
-                    oldKey,
-                    newKey,
-                    oldValue,
-                    newValue,
-                });
-            }
-        }
-
-        // Get all keys from the original config
-        const originalKeys = getAllKeys(config);
-
-        // Use lodash's defaultsDeep function to recursively apply default properties
-        config = _.defaultsDeep(config, defaultConfig);
-
-        // Get all keys from the updated config
-        const updatedKeys = getAllKeys(config);
-
-        // Find the keys that were added
-        const addedKeys = _.difference(updatedKeys, originalKeys);
-
-        if (addedKeys.length === 0 && migratedKeys.length === 0) {
-            return;
-        }
-
-        if (addedKeys.length > 0) {
-            console.log('Adding missing config values to config.yaml:', addedKeys);
-        }
-
-        if (migratedKeys.length > 0) {
-            console.log('Migrating config values in config.yaml:', migratedKeys);
-        }
-
-        fs.writeFileSync('./config.yaml', yaml.stringify(config));
-    } catch (error) {
-        console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error);
-    }
-}
-
 /**
  * Creates the default config files if they don't exist yet.
  */
@@ -283,58 +102,13 @@ function createDefaultFiles() {
     }
 }
 
-/**
- * Returns the MD5 hash of the given data.
- * @param {Buffer} data Input data
- * @returns {string} MD5 hash of the input data
- */
-function getMd5Hash(data) {
-    return crypto
-        .createHash('md5')
-        .update(new Uint8Array(data))
-        .digest('hex');
-}
-
-/**
- * Copies the WASM binaries from the sillytavern-transformers package to the dist folder.
- */
-function copyWasmFiles() {
-    if (!fs.existsSync('./dist')) {
-        fs.mkdirSync('./dist');
-    }
-
-    const listDir = fs.readdirSync('./node_modules/sillytavern-transformers/dist');
-
-    for (const file of listDir) {
-        if (file.endsWith('.wasm')) {
-            const sourcePath = `./node_modules/sillytavern-transformers/dist/${file}`;
-            const targetPath = `./dist/${file}`;
-
-            // Don't copy if the file already exists and is the same checksum
-            if (fs.existsSync(targetPath)) {
-                const sourceChecksum = getMd5Hash(fs.readFileSync(sourcePath));
-                const targetChecksum = getMd5Hash(fs.readFileSync(targetPath));
-
-                if (sourceChecksum === targetChecksum) {
-                    continue;
-                }
-            }
-
-            fs.copyFileSync(sourcePath, targetPath);
-            console.log(`${file} successfully copied to ./dist/${file}`);
-        }
-    }
-}
-
 try {
     // 0. Convert config.conf to config.yaml
     convertConfig();
     // 1. Create default config files
     createDefaultFiles();
-    // 2. Copy transformers WASM binaries from node_modules
-    copyWasmFiles();
-    // 3. Add missing config values
-    addMissingConfigValues();
+    // 2. Add missing config values
+    addMissingConfigValues(path.join(process.cwd(), './config.yaml'));
 } catch (error) {
     console.error(error);
 }
diff --git a/server.js b/server.js
index a5e03cd41b..1a40b4fcc2 100644
--- a/server.js
+++ b/server.js
@@ -1,393 +1,15 @@
 #!/usr/bin/env node
-
-// native node modules
-import path from 'node:path';
-import util from 'node:util';
-import net from 'node:net';
-import dns from 'node:dns';
-import process from 'node:process';
-import { fileURLToPath } from 'node:url';
-
-import cors from 'cors';
-import { csrfSync } from 'csrf-sync';
-import express from 'express';
-import compression from 'compression';
-import cookieSession from 'cookie-session';
-import multer from 'multer';
-import responseTime from 'response-time';
-import helmet from 'helmet';
-import bodyParser from 'body-parser';
-import open from 'open';
-
-// local library imports
-import './src/fetch-patch.js';
-import { serverEvents, EVENT_NAMES } from './src/server-events.js';
 import { CommandLineParser } from './src/command-line.js';
-import { loadPlugins } from './src/plugin-loader.js';
-import {
-    initUserStorage,
-    getCookieSecret,
-    getCookieSessionName,
-    ensurePublicDirectoriesExist,
-    getUserDirectoriesList,
-    migrateSystemPrompts,
-    migrateUserData,
-    requireLoginMiddleware,
-    setUserDataMiddleware,
-    shouldRedirectToLogin,
-    cleanUploads,
-    getSessionCookieAge,
-    verifySecuritySettings,
-    loginPageMiddleware,
-} from './src/users.js';
-
-import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
-import basicAuthMiddleware from './src/middleware/basicAuth.js';
-import getWhitelistMiddleware from './src/middleware/whitelist.js';
-import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
-import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
-import initRequestProxy from './src/request-proxy.js';
-import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
-import corsProxyMiddleware from './src/middleware/corsProxy.js';
-import {
-    getVersion,
-    color,
-    removeColorFormatting,
-    getSeparator,
-    safeReadFileSync,
-    setupLogLevel,
-    setWindowTitle,
-} from './src/util.js';
-import { UPLOADS_DIRECTORY } from './src/constants.js';
-import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
-
-// Routers
-import { router as usersPublicRouter } from './src/endpoints/users-public.js';
-import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
-import { checkForNewContent } from './src/endpoints/content-manager.js';
-import { init as settingsInit } from './src/endpoints/settings.js';
-import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
-import { diskCache } from './src/endpoints/characters.js';
-
-// Unrestrict console logs display limit
-util.inspect.defaultOptions.maxArrayLength = null;
-util.inspect.defaultOptions.maxStringLength = null;
-util.inspect.defaultOptions.depth = 4;
-
-// Set a working directory for the server
-const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
-console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
-process.chdir(serverDirectory);
-
-// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
-// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
-// Safe to remove once support for Node v20 is dropped.
-if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) {
-    // @ts-ignore
-    if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
-}
+import { serverDirectory } from './src/server-directory.js';
 
+// config.yaml will be set when parsing command line arguments
 const cliArgs = new CommandLineParser().parse(process.argv);
 globalThis.DATA_ROOT = cliArgs.dataRoot;
 globalThis.COMMAND_LINE_ARGS = cliArgs;
-
-if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
-    console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
-    process.exit(1);
-}
+process.chdir(serverDirectory);
 
 try {
-    if (cliArgs.dnsPreferIPv6) {
-        dns.setDefaultResultOrder('ipv6first');
-        console.log('Preferring IPv6 for DNS resolution');
-    } else {
-        dns.setDefaultResultOrder('ipv4first');
-        console.log('Preferring IPv4 for DNS resolution');
-    }
+    await import('./src/server-main.js');
 } catch (error) {
-    console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.');
-}
-
-const app = express();
-app.use(helmet({
-    contentSecurityPolicy: false,
-}));
-app.use(compression());
-app.use(responseTime());
-
-app.use(bodyParser.json({ limit: '200mb' }));
-app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' }));
-
-// CORS Settings //
-const CORS = cors({
-    origin: 'null',
-    methods: ['OPTIONS'],
-});
-
-app.use(CORS);
-
-if (cliArgs.listen && cliArgs.basicAuthMode) {
-    app.use(basicAuthMiddleware);
-}
-
-if (cliArgs.whitelistMode) {
-    const whitelistMiddleware = await getWhitelistMiddleware();
-    app.use(whitelistMiddleware);
-}
-
-if (cliArgs.listen) {
-    app.use(accessLoggerMiddleware());
-}
-
-if (cliArgs.enableCorsProxy) {
-    app.use('/proxy/:url(*)', corsProxyMiddleware);
-} else {
-    app.use('/proxy/:url(*)', async (_, res) => {
-        const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';
-        console.log(message);
-        res.status(404).send(message);
-    });
-}
-
-app.use(cookieSession({
-    name: getCookieSessionName(),
-    sameSite: 'lax',
-    httpOnly: true,
-    maxAge: getSessionCookieAge(),
-    secret: getCookieSecret(globalThis.DATA_ROOT),
-}));
-
-app.use(setUserDataMiddleware);
-
-// CSRF Protection //
-if (!cliArgs.disableCsrf) {
-    const csrfSyncProtection = csrfSync({
-        getTokenFromState: (req) => {
-            if (!req.session) {
-                console.error('(CSRF error) getTokenFromState: Session object not initialized');
-                return;
-            }
-            return req.session.csrfToken;
-        },
-        getTokenFromRequest: (req) => {
-            return req.headers['x-csrf-token']?.toString();
-        },
-        storeTokenInState: (req, token) => {
-            if (!req.session) {
-                console.error('(CSRF error) storeTokenInState: Session object not initialized');
-                return;
-            }
-            req.session.csrfToken = token;
-        },
-        size: 32,
-    });
-
-    app.get('/csrf-token', (req, res) => {
-        res.json({
-            'token': csrfSyncProtection.generateToken(req),
-        });
-    });
-
-    // Customize the error message
-    csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.');
-    csrfSyncProtection.invalidCsrfTokenError.stack = undefined;
-
-    app.use(csrfSyncProtection.csrfSynchronisedProtection);
-} else {
-    console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
-    app.get('/csrf-token', (req, res) => {
-        res.json({
-            'token': 'disabled',
-        });
-    });
-}
-
-// Static files
-// Host index page
-app.get('/', getCacheBusterMiddleware(), (request, response) => {
-    if (shouldRedirectToLogin(request)) {
-        const query = request.url.split('?')[1];
-        const redirectUrl = query ? `/login?${query}` : '/login';
-        return response.redirect(redirectUrl);
-    }
-
-    return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') });
-});
-
-// Callback endpoint for OAuth PKCE flows (e.g. OpenRouter)
-app.get('/callback/:source?', (request, response) => {
-    const source = request.params.source;
-    const query = request.url.split('?')[1];
-    const searchParams = new URLSearchParams();
-    source && searchParams.set('source', source);
-    query && searchParams.set('query', query);
-    const path = `/?${searchParams.toString()}`;
-    return response.redirect(307, path);
-});
-
-// Host login page
-app.get('/login', loginPageMiddleware);
-
-// Host frontend assets
-const webpackMiddleware = getWebpackServeMiddleware();
-app.use(webpackMiddleware);
-app.use(express.static(process.cwd() + '/public', {}));
-
-// Public API
-app.use('/api/users', usersPublicRouter);
-
-// Everything below this line requires authentication
-app.use(requireLoginMiddleware);
-app.get('/api/ping', (request, response) => {
-    if (request.query.extend && request.session) {
-        request.session.touch = Date.now();
-    }
-
-    response.sendStatus(204);
-});
-
-// File uploads
-const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
-app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
-app.use(multerMonkeyPatch);
-
-app.get('/version', async function (_, response) {
-    const data = await getVersion();
-    response.send(data);
-});
-
-redirectDeprecatedEndpoints(app);
-setupPrivateEndpoints(app);
-
-/**
- * Tasks that need to be run before the server starts listening.
- * @returns {Promise<void>}
- */
-async function preSetupTasks() {
-    const version = await getVersion();
-
-    // Print formatted header
-    console.log();
-    console.log(`SillyTavern ${version.pkgVersion}`);
-    if (version.gitBranch) {
-        console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}`);
-        if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) {
-            console.log('INFO: Currently not on the latest commit.');
-            console.log('      Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.');
-        }
-    }
-    console.log();
-
-    const directories = await getUserDirectoriesList();
-    await checkForNewContent(directories);
-    await ensureThumbnailCache(directories);
-    await diskCache.verify(directories);
-    cleanUploads();
-    migrateAccessLog();
-
-    await settingsInit();
-    await statsInit();
-
-    const pluginsDirectory = path.join(serverDirectory, 'plugins');
-    const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
-    const consoleTitle = process.title;
-
-    let isExiting = false;
-    const exitProcess = async () => {
-        if (isExiting) return;
-        isExiting = true;
-        await statsOnExit();
-        if (typeof cleanupPlugins === 'function') {
-            await cleanupPlugins();
-        }
-        diskCache.dispose();
-        setWindowTitle(consoleTitle);
-        process.exit();
-    };
-
-    // Set up event listeners for a graceful shutdown
-    process.on('SIGINT', exitProcess);
-    process.on('SIGTERM', exitProcess);
-    process.on('uncaughtException', (err) => {
-        console.error('Uncaught exception:', err);
-        exitProcess();
-    });
-
-    // Add request proxy.
-    initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass });
-
-    // Wait for frontend libs to compile
-    await webpackMiddleware.runWebpackCompiler();
-}
-
-/**
- * Tasks that need to be run after the server starts listening.
- * @param {import('./src/server-startup.js').ServerStartupResult} result The result of the server startup
- * @returns {Promise<void>}
- */
-async function postSetupTasks(result) {
-    const autorunHostname = await cliArgs.getAutorunHostname(result);
-    const autorunUrl = cliArgs.getAutorunUrl(autorunHostname);
-
-    if (cliArgs.autorun) {
-        try {
-            console.log('Launching in a browser...');
-            await open(autorunUrl.toString());
-        } catch (error) {
-            console.error('Failed to launch the browser. Open the URL manually.');
-        }
-    }
-
-    setWindowTitle('SillyTavern WebServer');
-
-    let logListen = 'SillyTavern is listening on';
-
-    if (result.useIPv6 && !result.v6Failed) {
-        logListen += color.green(
-            ' IPv6: ' + cliArgs.getIPv6ListenUrl().host,
-        );
-    }
-
-    if (result.useIPv4 && !result.v4Failed) {
-        logListen += color.green(
-            ' IPv4: ' + cliArgs.getIPv4ListenUrl().host,
-        );
-    }
-
-    const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern';
-    const plainGoToLog = removeColorFormatting(goToLog);
-
-    console.log(logListen);
-    if (cliArgs.listen) {
-        console.log();
-        console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
-        console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath()));
-    }
-    console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
-    console.log(goToLog);
-    console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
-
-    setupLogLevel();
-    serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: autorunUrl });
-}
-
-/**
- * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
- */
-function apply404Middleware() {
-    const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? '';
-    app.use((req, res) => {
-        res.status(404).send(notFoundWebpage);
-    });
+    console.error('A critical error has occurred while starting the server:', error);
 }
-
-// User storage module needs to be initialized before starting the server
-initUserStorage(globalThis.DATA_ROOT)
-    .then(ensurePublicDirectoriesExist)
-    .then(migrateUserData)
-    .then(migrateSystemPrompts)
-    .then(verifySecuritySettings)
-    .then(preSetupTasks)
-    .then(apply404Middleware)
-    .then(() => new ServerStartup(app, cliArgs).start())
-    .then(postSetupTasks);
diff --git a/src/command-line.js b/src/command-line.js
index 002f469263..cb1dd10159 100644
--- a/src/command-line.js
+++ b/src/command-line.js
@@ -2,9 +2,11 @@ import yargs from 'yargs/yargs';
 import { hideBin } from 'yargs/helpers';
 import ipRegex from 'ip-regex';
 import { canResolve, color, getConfigValue, stringToBool } from './util.js';
+import { initConfig } from './config-init.js';
 
 /**
  * @typedef {object} CommandLineArguments Parsed command line arguments
+ * @property {string} configPath Path to the config file
  * @property {string} dataRoot Data root directory
  * @property {number} port Port number
  * @property {boolean} listen If SillyTavern is listening on all network interfaces
@@ -40,6 +42,7 @@ export class CommandLineParser {
     constructor() {
         /** @type {CommandLineArguments} */
         this.default = Object.freeze({
+            configPath: './config.yaml',
             dataRoot: './data',
             port: 8000,
             listen: false,
@@ -88,6 +91,11 @@ export class CommandLineParser {
     parse(args) {
         const cliArguments = yargs(hideBin(args))
             .usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.')
+            .option('configPath', {
+                type: 'string',
+                default: null,
+                describe: 'Path to the config file',
+            })
             .option('enableIPv6', {
                 type: 'string',
                 default: null,
@@ -177,8 +185,11 @@ export class CommandLineParser {
                 describe: 'Request proxy bypass list (space separated list of hosts)',
             }).parseSync();
 
+        const configPath = cliArguments.configPath ?? this.default.configPath;
+        initConfig(configPath);
         /** @type {CommandLineArguments} */
         const result = {
+            configPath: configPath,
             dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot),
             port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'),
             listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'),
diff --git a/src/config-init.js b/src/config-init.js
new file mode 100644
index 0000000000..826929b500
--- /dev/null
+++ b/src/config-init.js
@@ -0,0 +1,197 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import yaml from 'yaml';
+import color from 'chalk';
+import _ from 'lodash';
+import { serverDirectory } from './server-directory.js';
+import { setConfigFilePath } from './util.js';
+
+const keyMigrationMap = [
+    {
+        oldKey: 'disableThumbnails',
+        newKey: 'thumbnails.enabled',
+        migrate: (value) => !value,
+    },
+    {
+        oldKey: 'thumbnailsQuality',
+        newKey: 'thumbnails.quality',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'avatarThumbnailsPng',
+        newKey: 'thumbnails.format',
+        migrate: (value) => (value ? 'png' : 'jpg'),
+    },
+    {
+        oldKey: 'disableChatBackup',
+        newKey: 'backups.chat.enabled',
+        migrate: (value) => !value,
+    },
+    {
+        oldKey: 'numberOfBackups',
+        newKey: 'backups.common.numberOfBackups',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'maxTotalChatBackups',
+        newKey: 'backups.chat.maxTotalBackups',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'chatBackupThrottleInterval',
+        newKey: 'backups.chat.throttleInterval',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'enableExtensions',
+        newKey: 'extensions.enabled',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'enableExtensionsAutoUpdate',
+        newKey: 'extensions.autoUpdate',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'extras.disableAutoDownload',
+        newKey: 'extensions.models.autoDownload',
+        migrate: (value) => !value,
+    },
+    {
+        oldKey: 'extras.classificationModel',
+        newKey: 'extensions.models.classification',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'extras.captioningModel',
+        newKey: 'extensions.models.captioning',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'extras.embeddingModel',
+        newKey: 'extensions.models.embedding',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'extras.speechToTextModel',
+        newKey: 'extensions.models.speechToText',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'extras.textToSpeechModel',
+        newKey: 'extensions.models.textToSpeech',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'minLogLevel',
+        newKey: 'logging.minLogLevel',
+        migrate: (value) => value,
+    },
+    {
+        oldKey: 'cardsCacheCapacity',
+        newKey: 'performance.memoryCacheCapacity',
+        migrate: (value) => `${value}mb`,
+    },
+    {
+        oldKey: 'cookieSecret',
+        newKey: 'cookieSecret',
+        migrate: () => void 0,
+        remove: true,
+    },
+];
+
+/**
+ * Gets all keys from an object recursively.
+ * @param {object} obj Object to get all keys from
+ * @param {string} prefix Prefix to prepend to all keys
+ * @returns {string[]} Array of all keys in the object
+ */
+function getAllKeys(obj, prefix = '') {
+    if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) {
+        return [];
+    }
+
+    return _.flatMap(Object.keys(obj), key => {
+        const newPrefix = prefix ? `${prefix}.${key}` : key;
+        if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
+            return getAllKeys(obj[key], newPrefix);
+        } else {
+            return [newPrefix];
+        }
+    });
+}
+
+/**
+ * Compares the current config.yaml with the default config.yaml and adds any missing values.
+ * @param {string} configPath Path to config.yaml
+ */
+export function addMissingConfigValues(configPath) {
+    try {
+        const defaultConfig = yaml.parse(fs.readFileSync(path.join(serverDirectory, './default/config.yaml'), 'utf8'));
+        let config = yaml.parse(fs.readFileSync(configPath, 'utf8'));
+
+        // Migrate old keys to new keys
+        const migratedKeys = [];
+        for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) {
+            if (_.has(config, oldKey)) {
+                if (remove) {
+                    _.unset(config, oldKey);
+                    migratedKeys.push({
+                        oldKey,
+                        newValue: void 0,
+                    });
+                    continue;
+                }
+
+                const oldValue = _.get(config, oldKey);
+                const newValue = migrate(oldValue);
+                _.set(config, newKey, newValue);
+                _.unset(config, oldKey);
+
+                migratedKeys.push({
+                    oldKey,
+                    newKey,
+                    oldValue,
+                    newValue,
+                });
+            }
+        }
+
+        // Get all keys from the original config
+        const originalKeys = getAllKeys(config);
+
+        // Use lodash's defaultsDeep function to recursively apply default properties
+        config = _.defaultsDeep(config, defaultConfig);
+
+        // Get all keys from the updated config
+        const updatedKeys = getAllKeys(config);
+
+        // Find the keys that were added
+        const addedKeys = _.difference(updatedKeys, originalKeys);
+
+        if (addedKeys.length === 0 && migratedKeys.length === 0) {
+            return;
+        }
+
+        if (addedKeys.length > 0) {
+            console.log('Adding missing config values to config.yaml:', addedKeys);
+        }
+
+        if (migratedKeys.length > 0) {
+            console.log('Migrating config values in config.yaml:', migratedKeys);
+        }
+
+        fs.writeFileSync(configPath, yaml.stringify(config));
+    } catch (error) {
+        console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error);
+    }
+}
+
+/**
+ * Performs early initialization tasks before the server starts.
+ * @param {string} configPath Path to config.yaml
+ */
+export function initConfig(configPath) {
+    setConfigFilePath(configPath);
+    addMissingConfigValues(configPath);
+}
diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js
index 6cebf53b76..15c3146c37 100644
--- a/src/endpoints/content-manager.js
+++ b/src/endpoints/content-manager.js
@@ -1,6 +1,5 @@
 import fs from 'node:fs';
 import path from 'node:path';
-import process from 'node:process';
 import { Buffer } from 'node:buffer';
 
 import express from 'express';
@@ -8,11 +7,12 @@ import fetch from 'node-fetch';
 import sanitize from 'sanitize-filename';
 import { sync as writeFileAtomicSync } from  'write-file-atomic';
 
-import { getConfigValue, color } from '../util.js';
+import { getConfigValue, color, setPermissionsSync } from '../util.js';
 import { write } from '../character-card-parser.js';
+import { serverDirectory } from '../server-directory.js';
 
-const contentDirectory = path.join(process.cwd(), 'default/content');
-const scaffoldDirectory = path.join(process.cwd(), 'default/scaffold');
+const contentDirectory = path.join(serverDirectory, 'default/content');
+const scaffoldDirectory = path.join(serverDirectory, 'default/scaffold');
 const contentIndexPath = path.join(contentDirectory, 'index.json');
 const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
 
@@ -149,6 +149,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
         }
 
         fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
+        setPermissionsSync(targetPath);
         console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
         anyContentAdded = true;
     }
diff --git a/src/fetch-patch.js b/src/fetch-patch.js
index c17b7b85c1..f6f5ded9e8 100644
--- a/src/fetch-patch.js
+++ b/src/fetch-patch.js
@@ -2,6 +2,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import mime from 'mime-types';
+import { serverDirectory } from './server-directory.js';
 
 const originalFetch = globalThis.fetch;
 
@@ -67,10 +68,9 @@ globalThis.fetch = async (/** @type {string | URL | Request} */ request, /** @ty
     }
     const url = getRequestURL(request);
     const filePath = path.resolve(fileURLToPath(url));
-    const cwd = path.resolve(process.cwd()) + path.sep;
-    const isUnderCwd = isPathUnderParent(cwd, filePath);
-    if (!isUnderCwd) {
-        throw new Error('Requested file path is outside of the current working directory.');
+    const isUnderServerDirectory = isPathUnderParent(serverDirectory, filePath);
+    if (!isUnderServerDirectory) {
+        throw new Error('Requested file path is outside of the server directory.');
     }
     const parsedPath = path.parse(filePath);
     if (!ALLOWED_EXTENSIONS.includes(parsedPath.ext)) {
diff --git a/src/server-directory.js b/src/server-directory.js
new file mode 100644
index 0000000000..6c0c6e1dac
--- /dev/null
+++ b/src/server-directory.js
@@ -0,0 +1,3 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+export const serverDirectory = path.dirname(import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)));
diff --git a/src/server-main.js b/src/server-main.js
new file mode 100644
index 0000000000..4e53444823
--- /dev/null
+++ b/src/server-main.js
@@ -0,0 +1,386 @@
+// native node modules
+import path from 'node:path';
+import util from 'node:util';
+import net from 'node:net';
+import dns from 'node:dns';
+import process from 'node:process';
+
+import cors from 'cors';
+import { csrfSync } from 'csrf-sync';
+import express from 'express';
+import compression from 'compression';
+import cookieSession from 'cookie-session';
+import multer from 'multer';
+import responseTime from 'response-time';
+import helmet from 'helmet';
+import bodyParser from 'body-parser';
+import open from 'open';
+
+// local library imports
+import './fetch-patch.js';
+import { serverDirectory } from './server-directory.js';
+
+console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
+
+// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
+// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
+// Safe to remove once support for Node v20 is dropped.
+if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) {
+    // @ts-ignore
+    if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
+}
+
+import { serverEvents, EVENT_NAMES } from './server-events.js';
+import { loadPlugins } from './plugin-loader.js';
+import {
+    initUserStorage,
+    getCookieSecret,
+    getCookieSessionName,
+    ensurePublicDirectoriesExist,
+    getUserDirectoriesList,
+    migrateSystemPrompts,
+    migrateUserData,
+    requireLoginMiddleware,
+    setUserDataMiddleware,
+    shouldRedirectToLogin,
+    cleanUploads,
+    getSessionCookieAge,
+    verifySecuritySettings,
+    loginPageMiddleware,
+} from './users.js';
+
+import getWebpackServeMiddleware from './middleware/webpack-serve.js';
+import basicAuthMiddleware from './middleware/basicAuth.js';
+import getWhitelistMiddleware from './middleware/whitelist.js';
+import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';
+import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
+import initRequestProxy from './request-proxy.js';
+import getCacheBusterMiddleware from './middleware/cacheBuster.js';
+import corsProxyMiddleware from './middleware/corsProxy.js';
+import {
+    getVersion,
+    color,
+    removeColorFormatting,
+    getSeparator,
+    safeReadFileSync,
+    setupLogLevel,
+    setWindowTitle,
+} from './util.js';
+import { UPLOADS_DIRECTORY } from './constants.js';
+import { ensureThumbnailCache } from './endpoints/thumbnails.js';
+
+// Routers
+import { router as usersPublicRouter } from './endpoints/users-public.js';
+import { init as statsInit, onExit as statsOnExit } from './endpoints/stats.js';
+import { checkForNewContent } from './endpoints/content-manager.js';
+import { init as settingsInit } from './endpoints/settings.js';
+import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
+import { diskCache } from './endpoints/characters.js';
+
+// Unrestrict console logs display limit
+util.inspect.defaultOptions.maxArrayLength = null;
+util.inspect.defaultOptions.maxStringLength = null;
+util.inspect.defaultOptions.depth = 4;
+
+const cliArgs = globalThis.COMMAND_LINE_ARGS;
+
+if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
+    console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
+    process.exit(1);
+}
+
+try {
+    if (cliArgs.dnsPreferIPv6) {
+        dns.setDefaultResultOrder('ipv6first');
+        console.log('Preferring IPv6 for DNS resolution');
+    } else {
+        dns.setDefaultResultOrder('ipv4first');
+        console.log('Preferring IPv4 for DNS resolution');
+    }
+} catch (error) {
+    console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.');
+}
+
+const app = express();
+app.use(helmet({
+    contentSecurityPolicy: false,
+}));
+app.use(compression());
+app.use(responseTime());
+
+app.use(bodyParser.json({ limit: '200mb' }));
+app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' }));
+
+// CORS Settings //
+const CORS = cors({
+    origin: 'null',
+    methods: ['OPTIONS'],
+});
+
+app.use(CORS);
+
+if (cliArgs.listen && cliArgs.basicAuthMode) {
+    app.use(basicAuthMiddleware);
+}
+
+if (cliArgs.whitelistMode) {
+    const whitelistMiddleware = await getWhitelistMiddleware();
+    app.use(whitelistMiddleware);
+}
+
+if (cliArgs.listen) {
+    app.use(accessLoggerMiddleware());
+}
+
+if (cliArgs.enableCorsProxy) {
+    app.use('/proxy/:url(*)', corsProxyMiddleware);
+} else {
+    app.use('/proxy/:url(*)', async (_, res) => {
+        const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';
+        console.log(message);
+        res.status(404).send(message);
+    });
+}
+
+app.use(cookieSession({
+    name: getCookieSessionName(),
+    sameSite: 'lax',
+    httpOnly: true,
+    maxAge: getSessionCookieAge(),
+    secret: getCookieSecret(globalThis.DATA_ROOT),
+}));
+
+app.use(setUserDataMiddleware);
+
+// CSRF Protection //
+if (!cliArgs.disableCsrf) {
+    const csrfSyncProtection = csrfSync({
+        getTokenFromState: (req) => {
+            if (!req.session) {
+                console.error('(CSRF error) getTokenFromState: Session object not initialized');
+                return;
+            }
+            return req.session.csrfToken;
+        },
+        getTokenFromRequest: (req) => {
+            return req.headers['x-csrf-token']?.toString();
+        },
+        storeTokenInState: (req, token) => {
+            if (!req.session) {
+                console.error('(CSRF error) storeTokenInState: Session object not initialized');
+                return;
+            }
+            req.session.csrfToken = token;
+        },
+        size: 32,
+    });
+
+    app.get('/csrf-token', (req, res) => {
+        res.json({
+            'token': csrfSyncProtection.generateToken(req),
+        });
+    });
+
+    // Customize the error message
+    csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.');
+    csrfSyncProtection.invalidCsrfTokenError.stack = undefined;
+
+    app.use(csrfSyncProtection.csrfSynchronisedProtection);
+} else {
+    console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
+    app.get('/csrf-token', (req, res) => {
+        res.json({
+            'token': 'disabled',
+        });
+    });
+}
+
+// Static files
+// Host index page
+app.get('/', getCacheBusterMiddleware(), (request, response) => {
+    if (shouldRedirectToLogin(request)) {
+        const query = request.url.split('?')[1];
+        const redirectUrl = query ? `/login?${query}` : '/login';
+        return response.redirect(redirectUrl);
+    }
+
+    return response.sendFile('index.html', { root: path.join(serverDirectory, 'public') });
+});
+
+// Callback endpoint for OAuth PKCE flows (e.g. OpenRouter)
+app.get('/callback/:source?', (request, response) => {
+    const source = request.params.source;
+    const query = request.url.split('?')[1];
+    const searchParams = new URLSearchParams();
+    source && searchParams.set('source', source);
+    query && searchParams.set('query', query);
+    const path = `/?${searchParams.toString()}`;
+    return response.redirect(307, path);
+});
+
+// Host login page
+app.get('/login', loginPageMiddleware);
+
+// Host frontend assets
+const webpackMiddleware = getWebpackServeMiddleware();
+app.use(webpackMiddleware);
+app.use(express.static(path.join(serverDirectory, 'public'), {}));
+
+// Public API
+app.use('/api/users', usersPublicRouter);
+
+// Everything below this line requires authentication
+app.use(requireLoginMiddleware);
+app.get('/api/ping', (request, response) => {
+    if (request.query.extend && request.session) {
+        request.session.touch = Date.now();
+    }
+
+    response.sendStatus(204);
+});
+
+// File uploads
+const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
+app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
+app.use(multerMonkeyPatch);
+
+app.get('/version', async function (_, response) {
+    const data = await getVersion();
+    response.send(data);
+});
+
+redirectDeprecatedEndpoints(app);
+setupPrivateEndpoints(app);
+
+/**
+ * Tasks that need to be run before the server starts listening.
+ * @returns {Promise<void>}
+ */
+async function preSetupTasks() {
+    const version = await getVersion();
+
+    // Print formatted header
+    console.log();
+    console.log(`SillyTavern ${version.pkgVersion}`);
+    if (version.gitBranch) {
+        console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}`);
+        if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) {
+            console.log('INFO: Currently not on the latest commit.');
+            console.log('      Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.');
+        }
+    }
+    console.log();
+
+    const directories = await getUserDirectoriesList();
+    await checkForNewContent(directories);
+    await ensureThumbnailCache(directories);
+    await diskCache.verify(directories);
+    cleanUploads();
+    migrateAccessLog();
+
+    await settingsInit();
+    await statsInit();
+
+    const pluginsDirectory = path.join(serverDirectory, 'plugins');
+    const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
+    const consoleTitle = process.title;
+
+    let isExiting = false;
+    const exitProcess = async () => {
+        if (isExiting) return;
+        isExiting = true;
+        await statsOnExit();
+        if (typeof cleanupPlugins === 'function') {
+            await cleanupPlugins();
+        }
+        diskCache.dispose();
+        setWindowTitle(consoleTitle);
+        process.exit();
+    };
+
+    // Set up event listeners for a graceful shutdown
+    process.on('SIGINT', exitProcess);
+    process.on('SIGTERM', exitProcess);
+    process.on('uncaughtException', (err) => {
+        console.error('Uncaught exception:', err);
+        exitProcess();
+    });
+
+    // Add request proxy.
+    initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass });
+
+    // Wait for frontend libs to compile
+    await webpackMiddleware.runWebpackCompiler();
+}
+
+/**
+ * Tasks that need to be run after the server starts listening.
+ * @param {import('./server-startup.js').ServerStartupResult} result The result of the server startup
+ * @returns {Promise<void>}
+ */
+async function postSetupTasks(result) {
+    const autorunHostname = await cliArgs.getAutorunHostname(result);
+    const autorunUrl = cliArgs.getAutorunUrl(autorunHostname);
+
+    if (cliArgs.autorun) {
+        try {
+            console.log('Launching in a browser...');
+            await open(autorunUrl.toString());
+        } catch (error) {
+            console.error('Failed to launch the browser. Open the URL manually.');
+        }
+    }
+
+    setWindowTitle('SillyTavern WebServer');
+
+    let logListen = 'SillyTavern is listening on';
+
+    if (result.useIPv6 && !result.v6Failed) {
+        logListen += color.green(
+            ' IPv6: ' + cliArgs.getIPv6ListenUrl().host,
+        );
+    }
+
+    if (result.useIPv4 && !result.v4Failed) {
+        logListen += color.green(
+            ' IPv4: ' + cliArgs.getIPv4ListenUrl().host,
+        );
+    }
+
+    const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern';
+    const plainGoToLog = removeColorFormatting(goToLog);
+
+    console.log(logListen);
+    if (cliArgs.listen) {
+        console.log();
+        console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
+        console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath()));
+    }
+    console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
+    console.log(goToLog);
+    console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
+
+    setupLogLevel();
+    serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: autorunUrl });
+}
+
+/**
+ * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
+ */
+function apply404Middleware() {
+    const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? '';
+    app.use((req, res) => {
+        res.status(404).send(notFoundWebpage);
+    });
+}
+
+// User storage module needs to be initialized before starting the server
+initUserStorage(globalThis.DATA_ROOT)
+    .then(ensurePublicDirectoriesExist)
+    .then(migrateUserData)
+    .then(migrateSystemPrompts)
+    .then(verifySecuritySettings)
+    .then(preSetupTasks)
+    .then(apply404Middleware)
+    .then(() => new ServerStartup(app, cliArgs).start())
+    .then(postSetupTasks);
diff --git a/src/transformers.js b/src/transformers.js
index 2bd03f4a52..1fdea760b4 100644
--- a/src/transformers.js
+++ b/src/transformers.js
@@ -5,6 +5,7 @@ import { Buffer } from 'node:buffer';
 
 import { pipeline, env, RawImage } from 'sillytavern-transformers';
 import { getConfigValue } from './util.js';
+import { serverDirectory } from './server-directory.js';
 
 configureTransformers();
 
@@ -12,7 +13,7 @@ function configureTransformers() {
     // Limit the number of threads to 1 to avoid issues on Android
     env.backends.onnx.wasm.numThreads = 1;
     // Use WASM from a local folder to avoid CDN connections
-    env.backends.onnx.wasm.wasmPaths = path.join(process.cwd(), 'dist') + path.sep;
+    env.backends.onnx.wasm.wasmPaths = path.join(serverDirectory, 'node_modules', 'sillytavern-transformers', 'dist') + path.sep;
 }
 
 const tasks = {
diff --git a/src/users.js b/src/users.js
index b02c81c739..c1e631de5f 100644
--- a/src/users.js
+++ b/src/users.js
@@ -18,6 +18,7 @@ import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FIL
 import { getConfigValue, color, delay, generateTimestamp } from './util.js';
 import { readSecret, writeSecret } from './endpoints/secrets.js';
 import { getContentOfType } from './endpoints/content-manager.js';
+import { serverDirectory } from './server-directory.js';
 
 export const KEY_PREFIX = 'user:';
 const AVATAR_PREFIX = 'avatar:';
@@ -905,7 +906,7 @@ export async function loginPageMiddleware(request, response) {
         console.error('Error during auto-login:', error);
     }
 
-    return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
+    return response.sendFile('login.html', { root: path.join(serverDirectory, 'public') });
 }
 
 /**
diff --git a/src/util.js b/src/util.js
index ec94ceb1aa..2cd933968f 100644
--- a/src/util.js
+++ b/src/util.js
@@ -15,13 +15,15 @@ import yauzl from 'yauzl';
 import mime from 'mime-types';
 import { default as simpleGit } from 'simple-git';
 import chalk from 'chalk';
-import { LOG_LEVELS } from './constants.js';
 import bytes from 'bytes';
+import { LOG_LEVELS } from './constants.js';
+import { serverDirectory } from './server-directory.js';
 
 /**
  * Parsed config object.
  */
 let CACHED_CONFIG = null;
+let CONFIG_PATH = null;
 
 /**
  * Converts a configuration key to an environment variable key.
@@ -31,23 +33,38 @@ let CACHED_CONFIG = null;
  */
 export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_');
 
+/**
+ * Set the config file path.
+ * @param {string} configFilePath Path to the config file
+ */
+export function setConfigFilePath(configFilePath) {
+    if (CONFIG_PATH !== null) {
+        console.error(color.red('Config file path already set. Please restart the server to change the config file path.'));
+    }
+    CONFIG_PATH = path.resolve(configFilePath);
+}
+
 /**
  * Returns the config object from the config.yaml file.
  * @returns {object} Config object
  */
 export function getConfig() {
+    if (CONFIG_PATH === null) {
+        console.trace();
+        console.error(color.red('No config file path set. Please set the config file path using setConfigFilePath().'));
+        process.exit(1);
+    }
     if (CACHED_CONFIG) {
         return CACHED_CONFIG;
     }
-
-    if (!fs.existsSync('./config.yaml')) {
+    if (!fs.existsSync(CONFIG_PATH)) {
         console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.'));
         console.error(color.red('The program will now exit.'));
         process.exit(1);
     }
 
     try {
-        const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
+        const config = yaml.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
         CACHED_CONFIG = config;
         return config;
     } catch (error) {
@@ -121,20 +138,19 @@ export async function getVersion() {
 
     try {
         const require = createRequire(import.meta.url);
-        const pkgJson = require(path.join(process.cwd(), './package.json'));
+        const pkgJson = require(path.join(serverDirectory, './package.json'));
         pkgVersion = pkgJson.version;
         if (commandExistsSync('git')) {
-            const git = simpleGit();
-            const cwd = process.cwd();
-            gitRevision = await git.cwd(cwd).revparse(['--short', 'HEAD']);
-            gitBranch = await git.cwd(cwd).revparse(['--abbrev-ref', 'HEAD']);
-            commitDate = await git.cwd(cwd).show(['-s', '--format=%ci', gitRevision]);
+            const git = simpleGit({ baseDir: serverDirectory });
+            gitRevision = await git.revparse(['--short', 'HEAD']);
+            gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']);
+            commitDate = await git.show(['-s', '--format=%ci', gitRevision]);
 
-            const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']);
+            const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']);
 
             // Might fail, but exception is caught. Just don't run anything relevant after in this block...
-            const localLatest = await git.cwd(cwd).revparse(['HEAD']);
-            const remoteLatest = await git.cwd(cwd).revparse([trackingBranch]);
+            const localLatest = await git.revparse(['HEAD']);
+            const remoteLatest = await git.revparse([trackingBranch]);
             isLatest = localLatest === remoteLatest;
         }
     }
@@ -1071,3 +1087,39 @@ export function mutateJsonString(jsonString, mutation) {
         return jsonString;
     }
 }
+
+/**
+ * Sets the permissions of a file or directory to be writable.
+ * @param {string} targetPath Path to the file or directory
+ */
+export function setPermissionsSync(targetPath) {
+    /**
+     * Appends writable permission to the file mode.
+     * @param {string} filePath Path to the file
+     * @param {fs.Stats} stats File stats
+     */
+    function appendWritablePermission(filePath, stats) {
+        const currentMode = stats.mode;
+        const newMode = currentMode | 0o200;
+        if (newMode != currentMode) {
+            fs.chmodSync(filePath, newMode);
+        }
+    }
+
+    try {
+        const stats = fs.statSync(targetPath);
+
+        if (stats.isDirectory()) {
+            appendWritablePermission(targetPath, stats);
+            const files = fs.readdirSync(targetPath);
+
+            files.forEach((file) => {
+                setPermissionsSync(path.join(targetPath, file));
+            });
+        } else {
+            appendWritablePermission(targetPath, stats);
+        }
+    } catch (error) {
+        console.error(`Error setting write permissions for ${targetPath}:`, error);
+    }
+}
diff --git a/webpack.config.js b/webpack.config.js
index 3579a3caee..1790de6dd2 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,6 +1,7 @@
 import process from 'node:process';
 import path from 'node:path';
 import isDocker from 'is-docker';
+import { serverDirectory } from './src/server-directory.js';
 
 /**
  * Get the Webpack configuration for the public/lib.js file.
@@ -40,7 +41,7 @@ export default function getPublicLibConfig(forceDist = false) {
 
     return {
         mode: 'production',
-        entry: './public/lib.js',
+        entry: path.join(serverDirectory, 'public/lib.js'),
         cache: {
             type: 'filesystem',
             cacheDirectory: cacheDirectory,


Test patch: