Skip to content

Conversation

@Flash1232
Copy link

@Flash1232 Flash1232 commented Nov 16, 2025

As discussed in stoatchat/self-hosted#176

TODOs:

would be cool ™️ (maybe in follow-up PR):

  • Add CSP and some basic hardening to nginx conf

@insertish
Copy link
Contributor

Just a heads-up, build:prod is specifically referring to our production deployment, it is maybe a bit confusingly worded and should probably be renamed or removed entirely.

@Flash1232
Copy link
Author

Making sure of that, thanks 👍

I'll be finishing up the MR presumably by the end of the weekend.

@Flash1232 Flash1232 force-pushed the dockerize branch 3 times, most recently from 2b62cdc to a33ef8a Compare November 23, 2025 19:29
Signed-off-by: Elvio Petillo <elvio.petillo@nanowaris.com>
Signed-off-by: Elvio Petillo <elvio.petillo@nanowaris.com>
@Flash1232 Flash1232 force-pushed the dockerize branch 2 times, most recently from 2bab8e2 to 2ee99bf Compare November 23, 2025 22:16
@Flash1232
Copy link
Author

Flash1232 commented Nov 23, 2025

Before handing over the PR for review, there are still a few things left to do:

  • Create multi-stage Dockerfile and accompanying compose.yml to develop in "devcontainer" as well as run prod setup standalone
  • Initial test of dev and prod target stages (login/register screens visible, no apparent issues in prod setup, dev setup auto-reloads when changing .env, config or any source files)
  • Test using the new service/image in self-hosted repo compose.yml (couldn't get the dockerized frontend to connect to the API, probably am not aware of a config or something something name resolution via caddy or container name?)
  • Test Github image publishing (@insertish is that something you have a template for anyways, or have specific requests for me to test this with?)
  • Document potential diffs or new dev flows using Docker

@Flash1232 Flash1232 changed the title feat: dockerize initial (WIP) feat: dockerize initial Nov 23, 2025
Signed-off-by: Elvio Petillo <elvio.petillo@nanowaris.com>
@Flash1232 Flash1232 force-pushed the dockerize branch 3 times, most recently from 5b559e6 to bae96e7 Compare December 14, 2025 16:08
Signed-off-by: Elvio Petillo <elvio.petillo@nanowaris.com>
@Flash1232 Flash1232 force-pushed the dockerize branch 2 times, most recently from 8551e27 to 5d66664 Compare December 14, 2025 16:37
Signed-off-by: Elvio Petillo <elvio.petillo@nanowaris.com>
Signed-off-by: Elvio Petillo <elvio.petillo@nanowaris.com>
@Flash1232
Copy link
Author

Flash1232 commented Dec 14, 2025

A couple more comments from my side:

  • I have noticed you are using a "self-hosted" runner for https://github.com/stoatchat/stoatchat/blob/main/.github/workflows/docker.yaml#L43 so you might want to change this from ubuntu-latest (or just let me know and I will change it) after initial review
  • Ran basic smoke tests using chore: Add new for-web docker image self-hosted#177 for the dockerized frontend but did not yet integrate the new voice server as I could not immediately figure out what was wrong after incorporating it into the compose stack and add corresponding settings to Revolt.toml (and as not to stall this PR even further)
  • Some assumptions were made to make it work with the self-hosted repo but this might need refinement according to your plans
  • I tried to account for both dev and prod needs but I have no experience working with this frontend repo and the choices might not be fully suitable (please feel free to suggest adjustments)
  • CI should just work ™️ (ran some tests on my side)

@Kildall
Copy link

Kildall commented Jan 2, 2026

@Flash1232 From my testing and from documentation this docker image is going to have pre-baked environment variables (VITE_* prefix), meaning that it would work fine for a static url that is known during development (i.e stoat.chat) but in a self-hosted environment it wouldn't really be useful since it needs to be able to change.

  DEFAULT_WS_URL:
    (import.meta.env.DEV ? import.meta.env.VITE_DEV_WS_URL : undefined) ??
    (import.meta.env.VITE_WS_URL as string) ??
    "wss://stoat.chat/events",

And from vite documentation:

Vite exposes certain constants under the special import.meta.env object. These constants are defined as global variables during dev and statically replaced at build time to make tree-shaking effective.

If instead of starting nginx instantly after we copy the files, we can execute a script to append the env variables to the index.html.

#!/bin/sh
set -e

build_env_js() {
    local env_vars="{"
    local first=true

    # Get all environment variables that start with VITE_
    for var in $(env | grep '^VITE_' | cut -d'=' -f1); do
        local value="${!var}"
        if [ "$first" = true ]; then
            first=false
        else
            env_vars="${env_vars}, "
        fi
        env_vars="${env_vars}\"$var\": \"$value\""
    done

    env_vars="${env_vars}}"
    # Build the javascript object
    echo "<script>window.__ENV__ = ${env_vars};</script>"
}

ENV_JS=$(build_env_js)

echo "Injecting runtime environment variables into HTML file..."
html_file="/usr/share/nginx/html/index.html"
if [ -f "$html_file" ]; then
    sed -i 's|</head>|'"${ENV_JS}"'\n</head>|g' "$html_file"
fi

echo "Starting nginx..."
exec nginx -g 'daemon off;'

then we can access these environment variables by parsing them at runtime:

declare global {
  interface Window {
    __ENV__?: Record<string, string>;
  }
}
const runtimeEnv =
  typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};


/**
 * Safely retrieves an environment variable, handling both build-time (import.meta.env)
 * and runtime (window.__ENV__) environments with proper fallbacks.
 */
function getEnvVar(key: string, fallback?: string): string {
  // In development, check import.meta.env first
  if (import.meta.env.DEV) {
    const value = import.meta.env[key];
    if (value && typeof value === "string" && value.trim()) {
      return value.trim();
    }
  }

  // Check runtime environment
  const runtimeValue = runtimeEnv[key];
  if (runtimeValue && typeof runtimeValue === "string" && runtimeValue.trim()) {
    return runtimeValue.trim();
  }

  // Return fallback or empty string
  return fallback || "";
}

/**
 * Safely retrieves an environment variable with dev-specific override support.
 * Checks VITE_DEV_{KEY} first in dev mode, then falls back to VITE_{KEY}.
 */
function getEnvVarWithDevOverride(key: string, fallback: string): string {
  if (import.meta.env.DEV) {
    // Try dev-specific override first
    const devValue = getEnvVar(`VITE_DEV_${key}`);
    if (devValue) return devValue;

    // Fall back to regular key
    const regularValue = getEnvVar(`VITE_${key}`);
    if (regularValue) return regularValue;
  }

  // In production, use runtime env
  return getEnvVar(`VITE_${key}`, fallback);
}

/**
 * Safely retrieves an optional environment variable that may not exist.
 */
function getOptionalEnvVar(key: string): string | undefined {
  const value = getEnvVar(key);
  return value || undefined;
}

const DEFAULT_API_URL = getEnvVarWithDevOverride(
  "API_URL",
  "https://stoat.chat/api",
);

I don't know if this would be a good pattern, or something that may not want to be implemented, just thought to let you know since I had to handle this to deploy the new for-web client, and to let you know.

@Flash1232
Copy link
Author

Flash1232 commented Jan 3, 2026

@Flash1232 From my testing and from documentation this docker image is going to have pre-baked environment variables (VITE_* prefix), meaning that it would work fine for a static url that is known during development (i.e stoat.chat) but in a self-hosted environment it wouldn't really be useful since it needs to be able to change.

Thanks for bringing this up, I've observed this now as well when testing with the updated self-hosted part.

I don't know if this would be a good pattern, or something that may not want to be implemented, just thought to let you know since I had to handle this to deploy the new for-web client, and to let you know.

For me, the problem we're facing is indeed a bit tricky if we want to solve it preserving some best-practices with Docker:

  • Currently, with my (still to-be) proposed self-hosted PR, the web client service will be created using "read-only=true" which prevents attackers from modifying code inside the container if they somehow ever reach container fs access. This obviously makes solutions like startup script replacements impossible (at least it does not work if the whole service is marked as "read-only", even if using some new features like post_start lifecycle hooks (which can even run as root temporarily).
  • Another approach would be to just let the self-hoster build the client (for-web) himself, providing the necessary env vars during build-time. This has the disadvantage that we would have to make the client a submodule which everyone has to download and self-compile.

I am reluctant to just sacrifice security for this.

So I was inspired by a different idea: As we already have an nginx inside the very same client service, we can just let it pass the env vars via SSI to the frontend. This lets us keep "read_only=true" as well as makes it possible to change the URLs at runtime. I will expand on this later on, but TLDR;

We can create tmpfs for nginx and provide it with "template configs": These can contain $ssiVarToSendToFrontend = "${VITE_API_URL}" directives which are resolved by nginx at startup (inside the mounted tmpfs with mode=1770 so that the tmpfs itself is writeable). Nginx will "modify" each file served to the client browser, inserting the correct environment values directly in the source via SSI. Nginx picks up whatever env is thrown at it in the compose service specification.

I would deem it semi-elegant as it requires us to set window.VITE_XXX vars in the frontend, which feels a little hacky still. But it would be an improvement to just modifying the image on startup.

@Kildall
Copy link

Kildall commented Jan 4, 2026

That's a great idea, you are right that having the script do the modifications is a security risk, great catch about using SSI since it practically solves the issue, regarding having to set window.VITE_XXX in the front, there's no way to actually escape that, the only issue I see here long term is that the Revolt.toml would be hard to embed in a system like this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants