diff --git a/.github/workflows/web_ci.yaml b/.github/workflows/web_ci.yaml
index f0c7b1fb..a4427d1f 100644
--- a/.github/workflows/web_ci.yaml
+++ b/.github/workflows/web_ci.yaml
@@ -65,3 +65,85 @@ jobs:
name: stats.html
path: dist/stats.html
retention-days: 30
+
+ docker-runtime-test:
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Build Docker image (runtime env injection test)
+ run: |
+ docker build -t appflowy-web-test:ci -f docker/Dockerfile .
+
+ - name: Test container fails without required env vars
+ run: |
+ set -e
+ echo "Testing container fails without environment variables..."
+ OUTPUT=$(docker run --rm appflowy-web-test:ci 2>&1 || true)
+ echo "$OUTPUT" | grep -q "ERROR: AF_BASE_URL environment variable is required" || (echo "ERROR: Container should fail without env vars" && exit 1)
+ echo "✓ Container correctly fails without required env vars"
+
+ - name: Run container with injected env vars
+ run: |
+ docker run -d --rm --name appflowy-web-test -p 8080:80 \
+ -e AF_BASE_URL=https://ci-backend.example.com \
+ -e AF_GOTRUE_URL=https://ci-backend.example.com/gotrue \
+ -e AF_WS_V2_URL=wss://ci-backend.example.com/ws/v2 \
+ appflowy-web-test:ci
+
+ - name: Wait for server to be ready
+ run: |
+ for i in $(seq 1 30); do
+ if curl -fsS http://localhost:8080/ >/dev/null 2>&1; then
+ echo "Server is up"; break; fi; sleep 1; done
+ curl -fsS http://localhost:8080/ >/dev/null
+
+ - name: Verify injected runtime config in index.html
+ run: |
+ set -e
+ echo "Fetching HTML content..."
+ HTML=$(curl -fsS http://localhost:8080/)
+
+ echo "Checking for __APP_CONFIG__ injection..."
+ echo "$HTML" | grep -q "window.__APP_CONFIG__" || (echo "ERROR: Missing window.__APP_CONFIG__" && exit 1)
+ echo "✓ Found window.__APP_CONFIG__"
+
+ echo "Verifying injected values..."
+ # Note: The config is injected as a single line with format: {AF_BASE_URL:'value',AF_GOTRUE_URL:'value',AF_WS_V2_URL:'value'}
+ echo "$HTML" | grep -q "AF_BASE_URL:'https://ci-backend.example.com'" || (echo "ERROR: AF_BASE_URL not correctly injected" && exit 1)
+ echo "✓ AF_BASE_URL correctly injected"
+
+ echo "$HTML" | grep -q "AF_GOTRUE_URL:'https://ci-backend.example.com/gotrue'" || (echo "ERROR: AF_GOTRUE_URL not correctly injected" && exit 1)
+ echo "✓ AF_GOTRUE_URL correctly injected"
+
+ echo "$HTML" | grep -q "AF_WS_V2_URL:'wss://ci-backend.example.com/ws/v2'" || (echo "ERROR: AF_WS_V2_URL not correctly injected" && exit 1)
+ echo "✓ AF_WS_V2_URL correctly injected"
+
+ echo "All runtime configuration values verified successfully!"
+
+ - name: Verify config format and structure
+ run: |
+ set -e
+ echo "Extracting and verifying configuration structure..."
+
+ # Extract the config object from HTML
+ CONFIG=$(curl -fsS http://localhost:8080/ | grep -o "window.__APP_CONFIG__={[^}]*}")
+ echo "Extracted config: $CONFIG"
+
+ # Verify it's a valid JavaScript object format
+ echo "$CONFIG" | grep -q "window.__APP_CONFIG__={AF_BASE_URL:'[^']*',AF_GOTRUE_URL:'[^']*',AF_WS_V2_URL:'[^']*'}" || \
+ (echo "ERROR: Config format is invalid" && exit 1)
+
+ echo "✓ Configuration format and structure verified"
+
+ - name: Show container logs on failure
+ if: failure()
+ run: |
+ docker logs appflowy-web-test || true
+
+ - name: Cleanup container
+ if: always()
+ run: |
+ docker rm -f appflowy-web-test || true
diff --git a/.gitignore b/.gitignore
index aee5e21a..d1d9d2ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,4 +33,5 @@ coverage
.nyc_output
cypress/snapshots/**/__diff_output__/
-**/.claude
\ No newline at end of file
+**/.claude
+cypress/screenshots
\ No newline at end of file
diff --git a/deploy.env b/deploy.env
index 1943513a..d2044de7 100644
--- a/deploy.env
+++ b/deploy.env
@@ -1,3 +1,3 @@
AF_BASE_URL=http://localhost
AF_GOTRUE_URL=http://localhost/gotrue
-AF_WS_URL=ws://localhost/ws/v1
+AF_WS_V2_URL=ws://localhost/ws/v2
\ No newline at end of file
diff --git a/dev.env b/dev.env
index dcde6f63..d1323d60 100644
--- a/dev.env
+++ b/dev.env
@@ -1,4 +1,3 @@
AF_BASE_URL=http://localhost:8000
AF_GOTRUE_URL=http://localhost:9999
-AF_WS_URL=ws://localhost:8000/ws/v1
AF_WS_V2_URL=ws://localhost:8000/ws/v2
\ No newline at end of file
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 09cf5452..9cc5145f 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -9,10 +9,14 @@ COPY . .
RUN corepack enable
RUN pnpm install
-
-RUN sed -i 's|https://test.appflowy.cloud||g' src/components/main/app.hooks.ts
+# Build without environment variables - they will be injected at runtime
RUN pnpm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html/
COPY --from=builder /app/docker/nginx.conf /etc/nginx/nginx.conf
+COPY --from=builder /app/docker/entrypoint.sh /docker-entrypoint.sh
+RUN chmod +x /docker-entrypoint.sh
+
+# Set the entrypoint
+ENTRYPOINT ["/docker-entrypoint.sh"]
diff --git a/docker/docker-compose.example.yml b/docker/docker-compose.example.yml
new file mode 100644
index 00000000..90a7791a
--- /dev/null
+++ b/docker/docker-compose.example.yml
@@ -0,0 +1,14 @@
+version: '3.8'
+
+services:
+ appflowy-web:
+ image: appflowy/appflowy_web:latest
+ ports:
+ - "80:80"
+ environment:
+ # REQUIRED: Configure these to match your AppFlowy backend URLs
+ # The container will fail to start if these are not set
+ - AF_BASE_URL=https://your-backend.example.com
+ - AF_GOTRUE_URL=https://your-backend.example.com/gotrue
+ - AF_WS_V2_URL=wss://your-backend.example.com/ws/v2
+ restart: unless-stopped
\ No newline at end of file
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100755
index 00000000..510b1696
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+set -e
+
+# Check required environment variables
+if [ -z "${AF_BASE_URL}" ]; then
+ echo "ERROR: AF_BASE_URL environment variable is required but not set"
+ echo "Please set AF_BASE_URL to your AppFlowy backend URL (e.g., https://your-backend.example.com)"
+ exit 1
+fi
+
+if [ -z "${AF_GOTRUE_URL}" ]; then
+ echo "ERROR: AF_GOTRUE_URL environment variable is required but not set"
+ echo "Please set AF_GOTRUE_URL to your GoTrue authentication service URL (e.g., https://your-backend.example.com/gotrue)"
+ exit 1
+fi
+
+if [ -z "${AF_WS_V2_URL}" ]; then
+ echo "ERROR: AF_WS_V2_URL environment variable is required but not set"
+ echo "Please set AF_WS_V2_URL to your WebSocket v2 URL (e.g., wss://your-backend.example.com/ws/v2)"
+ exit 1
+fi
+
+# Create inline config script
+CONFIG_SCRIPT=""
+
+# Inject the config script into index.html right before
+sed -i "s||${CONFIG_SCRIPT}|g" /usr/share/nginx/html/index.html
+
+echo "Runtime configuration injected:"
+echo " AF_BASE_URL: ${AF_BASE_URL}"
+echo " AF_GOTRUE_URL: ${AF_GOTRUE_URL}"
+echo " AF_WS_V2_URL: ${AF_WS_V2_URL}"
+
+# Start nginx
+exec nginx -g 'daemon off;'
\ No newline at end of file
diff --git a/src/components/_shared/gallery-preview/GalleryPreview.tsx b/src/components/_shared/gallery-preview/GalleryPreview.tsx
index 142c5be3..f7fdd27c 100644
--- a/src/components/_shared/gallery-preview/GalleryPreview.tsx
+++ b/src/components/_shared/gallery-preview/GalleryPreview.tsx
@@ -13,6 +13,7 @@ import { ReactComponent as DownloadIcon } from '@/assets/icons/save_as.svg';
import { notify } from '@/components/_shared/notify';
import { copyTextToClipboard } from '@/utils/copy';
import isURL from 'validator/lib/isURL';
+import { getConfigValue } from '@/utils/runtime-config';
export interface GalleryImage {
src: string;
@@ -101,7 +102,7 @@ function GalleryPreview({ images, open, onClose, previewIndex, workspaceId }: Ga
const fileId = images[index].src;
- return import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
+ return getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
}, [images, index, workspaceId]);
if (!open) {
diff --git a/src/components/database/components/cell/file-media/PreviewImage.tsx b/src/components/database/components/cell/file-media/PreviewImage.tsx
index 4504589e..80a9201b 100644
--- a/src/components/database/components/cell/file-media/PreviewImage.tsx
+++ b/src/components/database/components/cell/file-media/PreviewImage.tsx
@@ -3,6 +3,7 @@ import isURL from 'validator/lib/isURL';
import { useDatabaseContext } from '@/application/database-yjs';
import { FileMediaCellDataItem } from '@/application/database-yjs/cell.type';
+import { getConfigValue } from '@/utils/runtime-config';
function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick: () => void }) {
const { workspaceId } = useDatabaseContext();
@@ -11,7 +12,7 @@ function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick:
let fileUrl = file.url;
if (!isURL(file.url)) {
- fileUrl = import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + file.url;
+ fileUrl = getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + file.url;
}
const url = new URL(fileUrl);
diff --git a/src/components/database/components/cell/file-media/UnPreviewFile.tsx b/src/components/database/components/cell/file-media/UnPreviewFile.tsx
index f524c95f..a40cb012 100644
--- a/src/components/database/components/cell/file-media/UnPreviewFile.tsx
+++ b/src/components/database/components/cell/file-media/UnPreviewFile.tsx
@@ -6,6 +6,7 @@ import FileIcon from '@/components/database/components/cell/file-media/FileIcon'
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { openUrl } from '@/utils/url';
+import { getConfigValue } from '@/utils/runtime-config';
function UnPreviewFile({ file }: { file: FileMediaCellDataItem }) {
const { workspaceId } = useDatabaseContext();
@@ -25,7 +26,7 @@ function UnPreviewFile({ file }: { file: FileMediaCellDataItem }) {
}
const fileId = file.url;
- const newUrl = import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
+ const newUrl = getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
void openUrl(newUrl, '_blank');
}}
diff --git a/src/components/database/components/database-row/file-media/FileMediaItem.tsx b/src/components/database/components/database-row/file-media/FileMediaItem.tsx
index a0c10e36..f2cc8e81 100644
--- a/src/components/database/components/database-row/file-media/FileMediaItem.tsx
+++ b/src/components/database/components/database-row/file-media/FileMediaItem.tsx
@@ -8,6 +8,7 @@ import FileMediaMore from '@/components/database/components/cell/file-media/File
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { openUrl } from '@/utils/url';
+import { getConfigValue } from '@/utils/runtime-config';
function FileMediaItem({
file,
@@ -53,7 +54,7 @@ function FileMediaItem({
const fileId = file.url;
- return import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
+ return getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
}, [file.url, workspaceId]);
const [hover, setHover] = useState(false);
@@ -70,7 +71,7 @@ function FileMediaItem({
}
const fileId = file.url;
- const newUrl = import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
+ const newUrl = getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
void openUrl(newUrl, '_blank');
}
diff --git a/src/components/editor/components/blocks/file/FileBlock.tsx b/src/components/editor/components/blocks/file/FileBlock.tsx
index b43a125e..a667ca2f 100644
--- a/src/components/editor/components/blocks/file/FileBlock.tsx
+++ b/src/components/editor/components/blocks/file/FileBlock.tsx
@@ -16,6 +16,7 @@ import { EditorElementProps, FileNode } from '@/components/editor/editor.type';
import { useEditorContext } from '@/components/editor/EditorContext';
import { notify } from '@/components/_shared/notify';
import { FileHandler } from '@/utils/file';
+import { getConfigValue } from '@/utils/runtime-config';
import { openUrl } from '@/utils/url';
export const FileBlock = memo(
@@ -41,7 +42,7 @@ export const FileBlock = memo(
const fileId = dataUrl;
- return import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
+ return getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
}, [dataUrl, workspaceId]);
const className = useMemo(() => {
diff --git a/src/components/editor/components/blocks/gallery/GalleryBlock.tsx b/src/components/editor/components/blocks/gallery/GalleryBlock.tsx
index 32453c4f..f0252fbc 100644
--- a/src/components/editor/components/blocks/gallery/GalleryBlock.tsx
+++ b/src/components/editor/components/blocks/gallery/GalleryBlock.tsx
@@ -13,6 +13,7 @@ import { useEditorContext } from '@/components/editor/EditorContext';
import { GalleryPreview } from '@/components/_shared/gallery-preview';
import { notify } from '@/components/_shared/notify';
import { copyTextToClipboard } from '@/utils/copy';
+import { getConfigValue } from '@/utils/runtime-config';
const GalleryBlock = memo(
forwardRef>(({ node, children, ...attributes }, ref) => {
@@ -34,7 +35,7 @@ const GalleryBlock = memo(
let imageUrl = image.url;
if (!isURL(image.url)) {
- imageUrl = import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + image.url;
+ imageUrl = getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + image.url;
}
const url = new URL(imageUrl);
diff --git a/src/components/editor/components/blocks/image/ImageBlock.tsx b/src/components/editor/components/blocks/image/ImageBlock.tsx
index 71d23a18..8df293ea 100644
--- a/src/components/editor/components/blocks/image/ImageBlock.tsx
+++ b/src/components/editor/components/blocks/image/ImageBlock.tsx
@@ -17,6 +17,7 @@ import { FileHandler } from '@/utils/file';
import ImageEmpty from './ImageEmpty';
import ImageRender from './ImageRender';
+import { getConfigValue } from '@/utils/runtime-config';
export const ImageBlock = memo(
forwardRef>(({ node, children, ...attributes }, ref) => {
@@ -43,7 +44,7 @@ export const ImageBlock = memo(
const fileId = dataUrl;
- return import.meta.env.AF_BASE_URL + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
+ return getConfigValue('AF_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + fileId;
}, [dataUrl, workspaceId]);
const containerRef = useRef(null);
diff --git a/src/components/main/app.hooks.ts b/src/components/main/app.hooks.ts
index 7a8db85a..a9a697a7 100644
--- a/src/components/main/app.hooks.ts
+++ b/src/components/main/app.hooks.ts
@@ -1,16 +1,16 @@
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { User } from '@/application/types';
+import { getConfigValue } from '@/utils/runtime-config';
import { createContext, useContext } from 'react';
-const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud';
-const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue';
-const wsURL = import.meta.env.AF_WS_URL || 'wss://test.appflowy.cloud/ws/v1';
+const baseURL = getConfigValue('AF_BASE_URL', 'https://test.appflowy.cloud');
+const gotrueURL = getConfigValue('AF_GOTRUE_URL', 'https://test.appflowy.cloud/gotrue');
export const defaultConfig: AFServiceConfig = {
cloudConfig: {
baseURL,
gotrueURL,
- wsURL,
+ wsURL: '', // Legacy field - not used, keeping for backward compatibility
},
};
diff --git a/src/components/ws/useAppflowyWebSocket.ts b/src/components/ws/useAppflowyWebSocket.ts
index 31a91ce6..407a5d5c 100644
--- a/src/components/ws/useAppflowyWebSocket.ts
+++ b/src/components/ws/useAppflowyWebSocket.ts
@@ -3,9 +3,10 @@ import { useCallback, useMemo, useState } from 'react';
import useWebSocket from 'react-use-websocket';
import { getTokenParsed } from '@/application/session/token';
+import { getConfigValue } from '@/utils/runtime-config';
import { messages } from '@/proto/messages';
-const wsURL = import.meta.env.AF_WS_V2_URL || 'ws://localhost:8000/ws/v2';
+const wsURL = getConfigValue('AF_WS_V2_URL', 'ws://localhost:8000/ws/v2');
// WebSocket close code enum
enum CloseCode {
diff --git a/src/utils/runtime-config.ts b/src/utils/runtime-config.ts
new file mode 100644
index 00000000..4c84a94f
--- /dev/null
+++ b/src/utils/runtime-config.ts
@@ -0,0 +1,31 @@
+interface RuntimeConfig {
+ AF_BASE_URL?: string;
+ AF_GOTRUE_URL?: string;
+ AF_WS_V2_URL?: string;
+}
+
+declare global {
+ interface Window {
+ __APP_CONFIG__?: RuntimeConfig;
+ }
+}
+
+export function getConfigValue(key: keyof RuntimeConfig, defaultValue: string): string {
+ // First check runtime config (injected by Docker entrypoint)
+ if (typeof window !== 'undefined' && window.__APP_CONFIG__) {
+ const runtimeValue = window.__APP_CONFIG__[key];
+
+ if (runtimeValue && !runtimeValue.startsWith('${')) {
+ return runtimeValue;
+ }
+ }
+
+ // Fall back to build-time environment variables from Vite
+ const envValue = import.meta.env[key];
+
+ if (envValue) {
+ return envValue;
+ }
+
+ return defaultValue;
+}
\ No newline at end of file