Compare commits

...

6 Commits

Author SHA1 Message Date
Tiago Farto
a5053b0a9c chore: refactoring 2026-04-15 16:21:11 +00:00
Tiago Farto
662ea7eca3 chore: fix workflow 2026-04-15 15:09:38 +00:00
Tiago Farto
6323250ccd feat: replace minio with rustfs 2026-04-15 14:38:18 +00:00
XHamzaX
a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Marius
0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey
b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
17 changed files with 1386 additions and 356 deletions

View File

@@ -85,12 +85,12 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_ACCESS_KEY=devrustfs" >> .env
echo "S3_SECRET_KEY=devrustfs123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Install MinIO client (mc)
- name: Install S3 bootstrap client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
@@ -106,31 +106,36 @@ jobs:
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
- name: Start RustFS Server
run: |
set -euo pipefail
# Start MinIO server in background
# Start RustFS server in background
docker run -d \
--name minio-server \
--name rustfs-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
echo "MinIO server started"
echo "RustFS server started"
- name: Wait for MinIO and create S3 bucket
- name: Wait for RustFS and create S3 bucket
run: |
set -euo pipefail
echo "Waiting for MinIO to be ready..."
echo "Waiting for RustFS to be ready for S3 requests..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
if curl -fsS http://localhost:9000/health/ready >/dev/null && \
mc alias set local http://localhost:9000 devrustfs devrustfs123 >/dev/null 2>&1 && \
mc ls local >/dev/null 2>&1; then
echo "RustFS is ready after ${i} seconds"
ready=1
break
fi
@@ -138,11 +143,11 @@ jobs:
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
echo "::error::RustFS did not become ready for S3 requests within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc alias set local http://localhost:9000 devrustfs devrustfs123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
@@ -242,8 +247,14 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: cat app.log
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi

View File

@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -175,10 +175,34 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(

View File

@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -129,6 +129,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
}

View File

@@ -1,6 +1,6 @@
import { UAParser } from "ua-parser-js";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -177,6 +177,10 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,

View File

@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
});
});
describe("conflictResponse", () => {
test("should return a conflict response", () => {
const message = "Resource already exists";
const details = { field: "singleUseId" };
const response = responses.conflictResponse(message, details);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message, undefined, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Resource already exists";
const customCache = "no-cache";
const response = responses.conflictResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";

View File

@@ -16,7 +16,8 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests";
| "too_many_requests"
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
);
};
const conflictResponse = (
message: string,
details?: { [key: string]: string },
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "conflict",
message,
details: details || {},
} as ApiErrorResponse,
{
status: 409,
headers,
}
);
};
const tooManyRequestsResponse = (
message: string,
cors: boolean = false,
@@ -270,4 +295,5 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};

View File

@@ -98,14 +98,11 @@ describe("Users Lib", () => {
test("returns conflict error if user with email already exists", async () => {
(prisma.user.create as any).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`email`)",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
}
)
new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
})
);
const result = await createUser(
{ name: "Duplicate", email: "test@example.com", role: "member" },

View File

@@ -46,9 +46,9 @@ export const OpenIdButton = ({
type="button"
onClick={handleLogin}
variant="secondary"
className="relative w-full justify-center">
{text ? text : t("auth.continue_with_openid")}
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
className="w-full items-center justify-center gap-2 px-2">
<span className="truncate">{text || t("auth.continue_with_openid")}</span>
{lastUsed && <span className="shrink-0 text-xs opacity-50">{t("auth.last_used")}</span>}
</Button>
);
};

View File

@@ -24,22 +24,63 @@ services:
volumes:
- valkey-data:/data
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address ":9001"
rustfs-perms:
image: busybox:1.36.1
user: "0:0"
command: ["sh", "-c", "mkdir -p /data && chown -R 10001:10001 /data"]
volumes:
- rustfs-data:/data
rustfs:
image: rustfs/rustfs:1.0.0-alpha.93
depends_on:
rustfs-perms:
condition: service_completed_successfully
command: /data
environment:
- MINIO_ROOT_USER=devminio
- MINIO_ROOT_PASSWORD=devminio123
- RUSTFS_ACCESS_KEY=devrustfs
- RUSTFS_SECRET_KEY=devrustfs123
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_CONSOLE_ADDRESS=:9001
ports:
- "9000:9000" # S3 API direct access
- "9001:9001" # Web console
volumes:
- minio-data:/data
- rustfs-data:/data
rustfs-init:
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
depends_on:
- rustfs
environment:
- RUSTFS_ADMIN_USER=devrustfs
- RUSTFS_ADMIN_PASSWORD=devrustfs123
- RUSTFS_BUCKET_NAME=formbricks
entrypoint:
- /bin/sh
- -c
- |
echo 'Waiting for RustFS to be ready...'
attempts=0
max_attempts=30
until mc alias set rustfs http://rustfs:9000 "$$RUSTFS_ADMIN_USER" "$$RUSTFS_ADMIN_PASSWORD" >/dev/null 2>&1 \
&& mc ls rustfs >/dev/null 2>&1; do
attempts=$$((attempts + 1))
if [ "$$attempts" -ge "$$max_attempts" ]; then
echo "RustFS did not become ready within $${max_attempts} attempts"
exit 1
fi
sleep 2
done
mc mb rustfs/"$$RUSTFS_BUCKET_NAME" --ignore-existing
echo 'RustFS bucket bootstrap complete.'
volumes:
postgres:
driver: local
valkey-data:
driver: local
minio-data:
rustfs-data:
driver: local

View File

@@ -273,7 +273,7 @@ EOT
if [[ -z $configure_uploads ]]; then configure_uploads="y"; fi
if [[ $configure_uploads == "y" ]]; then
# Storage choice: External S3 vs bundled MinIO
# Storage choice: External S3 vs bundled RustFS
read -p "🗄️ Do you want to use an external S3-compatible storage (AWS S3/DO Spaces/etc.)? [y/N] " use_external_s3
use_external_s3=$(echo "$use_external_s3" | tr '[:upper:]' '[:lower:]')
if [[ -z $use_external_s3 ]]; then use_external_s3="n"; fi
@@ -286,29 +286,29 @@ EOT
read -p " S3 Bucket Name: " ext_s3_bucket
read -p " S3 Endpoint URL (leave empty if you are using AWS S3, otherwise please enter the endpoint URL of the third party S3 compatible storage service): " ext_s3_endpoint
minio_storage="n"
rustfs_storage="n"
else
minio_storage="y"
rustfs_storage="y"
default_files_domain="files.$domain_name"
read -p "🔗 Enter the files subdomain for object storage (e.g., $default_files_domain): " files_domain
if [[ -z $files_domain ]]; then files_domain="$default_files_domain"; fi
echo "🔑 Generating MinIO credentials..."
minio_root_user="formbricks-$(openssl rand -hex 4)"
minio_root_password=$(openssl rand -base64 20)
minio_service_user="formbricks-service-$(openssl rand -hex 4)"
minio_service_password=$(openssl rand -base64 20)
minio_bucket_name="formbricks-uploads"
minio_policy_name="formbricks-policy"
echo "🔑 Generating RustFS credentials..."
rustfs_admin_user="formbricks-$(openssl rand -hex 4)"
rustfs_admin_password=$(openssl rand -base64 20)
rustfs_service_user="formbricks-service-$(openssl rand -hex 4)"
rustfs_service_password=$(openssl rand -base64 20)
rustfs_bucket_name="formbricks-uploads"
rustfs_policy_name="formbricks-policy"
echo "✅ MinIO will be configured with:"
echo " S3 Access Key (least privilege): $minio_service_user"
echo " Bucket: $minio_bucket_name"
echo "✅ RustFS will be configured with:"
echo " S3 Access Key (least privilege): $rustfs_service_user"
echo " Bucket: $rustfs_bucket_name"
fi
else
minio_storage="n"
rustfs_storage="n"
use_external_s3="n"
echo "⚠️ File uploads are disabled. Proceeding without S3/MinIO configuration."
echo "⚠️ File uploads are disabled. Proceeding without S3-compatible storage configuration."
fi
echo "📥 Downloading docker-compose.yml from Formbricks GitHub repository..."
@@ -353,20 +353,20 @@ EOT
sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1# S3_FORCE_PATH_STYLE:|' docker-compose.yml
fi
echo "🚗 External S3 configuration updated successfully!"
elif [[ $minio_storage == "y" ]]; then
echo "🚗 Configuring bundled MinIO..."
sed -i "s|# S3_ACCESS_KEY:|S3_ACCESS_KEY: \"$minio_service_user\"|" docker-compose.yml
sed -i "s|# S3_SECRET_KEY:|S3_SECRET_KEY: \"$minio_service_password\"|" docker-compose.yml
elif [[ $rustfs_storage == "y" ]]; then
echo "🚗 Configuring bundled RustFS..."
sed -i "s|# S3_ACCESS_KEY:|S3_ACCESS_KEY: \"$rustfs_service_user\"|" docker-compose.yml
sed -i "s|# S3_SECRET_KEY:|S3_SECRET_KEY: \"$rustfs_service_password\"|" docker-compose.yml
sed -i "s|# S3_REGION:|S3_REGION: \"us-east-1\"|" docker-compose.yml
sed -i "s|# S3_BUCKET_NAME:|S3_BUCKET_NAME: \"$minio_bucket_name\"|" docker-compose.yml
sed -i "s|# S3_BUCKET_NAME:|S3_BUCKET_NAME: \"$rustfs_bucket_name\"|" docker-compose.yml
if [[ $https_setup == "y" ]]; then
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"https://$files_domain\"|" docker-compose.yml
else
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"http://$files_domain\"|" docker-compose.yml
fi
# Ensure S3_FORCE_PATH_STYLE is enabled for MinIO
# Ensure S3_FORCE_PATH_STYLE is enabled for RustFS
sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1S3_FORCE_PATH_STYLE: 1|' docker-compose.yml
echo "🚗 MinIO S3 configuration updated successfully!"
echo "🚗 RustFS S3 configuration updated successfully!"
fi
# SUPER SIMPLE: Use multiple simple operations instead of complex AWK
@@ -400,8 +400,8 @@ EOT
{ print }
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
# Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on)
if [[ $minio_storage == "y" ]]; then
# Step 2: Ensure formbricks waits for rustfs-init to complete successfully (mapping depends_on)
if [[ $rustfs_storage == "y" ]]; then
# Remove any existing simple depends_on list and replace with mapping
awk '
BEGIN{in_fb=0; removing=0}
@@ -422,7 +422,9 @@ EOT
print " depends_on:"
print " postgres:"
print " condition: service_started"
print " minio-init:"
print " redis:"
print " condition: service_started"
print " rustfs-init:"
print " condition: service_completed_successfully"
inserted=1
}
@@ -437,59 +439,76 @@ EOT
insert_traefik="y"
if grep -q "^ traefik:" docker-compose.yml; then insert_traefik="n"; fi
if [[ $minio_storage == "y" ]]; then
insert_minio="y"; insert_minio_init="y"
if grep -q "^ minio:" docker-compose.yml; then insert_minio="n"; fi
if grep -q "^ minio-init:" docker-compose.yml; then insert_minio_init="n"; fi
if [[ $rustfs_storage == "y" ]]; then
insert_rustfs_perms="y"; insert_rustfs="y"; insert_rustfs_init="y"
if grep -q "^ rustfs-perms:" docker-compose.yml; then insert_rustfs_perms="n"; fi
if grep -q "^ rustfs:" docker-compose.yml; then insert_rustfs="n"; fi
if grep -q "^ rustfs-init:" docker-compose.yml; then insert_rustfs_init="n"; fi
if [[ $insert_minio == "y" ]]; then
if [[ $insert_rustfs_perms == "y" ]]; then
cat >> "$services_snippet_file" << EOF
minio:
restart: always
image: minio/minio@sha256:13582eff79c6605a2d315bdd0e70164142ea7e98fc8411e9e10d089502a6d883
command: server /data
environment:
MINIO_ROOT_USER: "$minio_root_user"
MINIO_ROOT_PASSWORD: "$minio_root_password"
rustfs-perms:
image: busybox:1.36.1
user: "0:0"
command: ["sh", "-c", "mkdir -p /data && chown -R 10001:10001 /data"]
volumes:
- minio-data:/data
labels:
- "traefik.enable=true"
# S3 API on files subdomain
- "traefik.http.routers.minio-s3.rule=Host(\`$files_domain\`)"
- "traefik.http.routers.minio-s3.entrypoints=websecure"
- "traefik.http.routers.minio-s3.tls=true"
- "traefik.http.routers.minio-s3.tls.certresolver=default"
- "traefik.http.routers.minio-s3.service=minio-s3"
- "traefik.http.services.minio-s3.loadbalancer.server.port=9000"
# CORS and rate limit (adjust origins if needed)
- "traefik.http.routers.minio-s3.middlewares=minio-cors,minio-ratelimit"
- "traefik.http.middlewares.minio-cors.headers.accesscontrolallowmethods=GET,PUT,POST,DELETE,HEAD,OPTIONS"
- "traefik.http.middlewares.minio-cors.headers.accesscontrolallowheaders=*"
- "traefik.http.middlewares.minio-cors.headers.accesscontrolalloworiginlist=https://$domain_name"
- "traefik.http.middlewares.minio-cors.headers.accesscontrolmaxage=100"
- "traefik.http.middlewares.minio-cors.headers.addvaryheader=true"
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
- rustfs-data:/data
EOF
fi
if [[ $insert_minio_init == "y" ]]; then
if [[ $insert_rustfs == "y" ]]; then
cat >> "$services_snippet_file" << EOF
minio-init:
rustfs:
restart: always
image: rustfs/rustfs:1.0.0-alpha.93
depends_on:
rustfs-perms:
condition: service_completed_successfully
command: /data
environment:
RUSTFS_ACCESS_KEY: "$rustfs_admin_user"
RUSTFS_SECRET_KEY: "$rustfs_admin_password"
RUSTFS_ADDRESS: ":9000"
volumes:
- rustfs-data:/data
labels:
- "traefik.enable=true"
# S3 API on files subdomain
- "traefik.http.routers.rustfs-s3.rule=Host(\`$files_domain\`)"
- "traefik.http.routers.rustfs-s3.entrypoints=websecure"
- "traefik.http.routers.rustfs-s3.tls=true"
- "traefik.http.routers.rustfs-s3.tls.certresolver=default"
- "traefik.http.routers.rustfs-s3.service=rustfs-s3"
- "traefik.http.services.rustfs-s3.loadbalancer.server.port=9000"
# CORS and rate limit (adjust origins if needed)
- "traefik.http.routers.rustfs-s3.middlewares=rustfs-cors,rustfs-ratelimit"
- "traefik.http.middlewares.rustfs-cors.headers.accesscontrolallowmethods=GET,PUT,POST,DELETE,HEAD,OPTIONS"
- "traefik.http.middlewares.rustfs-cors.headers.accesscontrolallowheaders=*"
- "traefik.http.middlewares.rustfs-cors.headers.accesscontrolalloworiginlist=https://$domain_name"
- "traefik.http.middlewares.rustfs-cors.headers.accesscontrolmaxage=100"
- "traefik.http.middlewares.rustfs-cors.headers.addvaryheader=true"
- "traefik.http.middlewares.rustfs-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.rustfs-ratelimit.ratelimit.burst=200"
EOF
fi
if [[ $insert_rustfs_init == "y" ]]; then
cat >> "$services_snippet_file" << EOF
rustfs-init:
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
depends_on:
- minio
- rustfs
environment:
MINIO_ROOT_USER: "$minio_root_user"
MINIO_ROOT_PASSWORD: "$minio_root_password"
MINIO_SERVICE_USER: "$minio_service_user"
MINIO_SERVICE_PASSWORD: "$minio_service_password"
MINIO_BUCKET_NAME: "$minio_bucket_name"
entrypoint: ["/bin/sh", "/tmp/minio-init.sh"]
RUSTFS_ADMIN_USER: "$rustfs_admin_user"
RUSTFS_ADMIN_PASSWORD: "$rustfs_admin_password"
RUSTFS_SERVICE_USER: "$rustfs_service_user"
RUSTFS_SERVICE_PASSWORD: "$rustfs_service_password"
RUSTFS_BUCKET_NAME: "$rustfs_bucket_name"
RUSTFS_POLICY_NAME: "$rustfs_policy_name"
entrypoint: ["/bin/sh", "/tmp/rustfs-init.sh"]
volumes:
- ./minio-init.sh:/tmp/minio-init.sh:ro
- ./rustfs-init.sh:/tmp/rustfs-init.sh:ro
EOF
fi
@@ -501,7 +520,7 @@ EOF
container_name: "traefik"
depends_on:
- formbricks
- minio
- rustfs
ports:
- "80:80"
- "443:443"
@@ -513,11 +532,11 @@ EOF
EOF
fi
# Downgrade MinIO router to plain HTTP when HTTPS is not configured
# Downgrade RustFS router to plain HTTP when HTTPS is not configured
if [[ $https_setup != "y" ]]; then
sed -i 's/traefik.http.routers.minio-s3.entrypoints=websecure/traefik.http.routers.minio-s3.entrypoints=web/' "$services_snippet_file"
sed -i '/traefik.http.routers.minio-s3.tls=true/d' "$services_snippet_file"
sed -i '/traefik.http.routers.minio-s3.tls.certresolver=default/d' "$services_snippet_file"
sed -i 's/traefik.http.routers.rustfs-s3.entrypoints=websecure/traefik.http.routers.rustfs-s3.entrypoints=web/' "$services_snippet_file"
sed -i '/traefik.http.routers.rustfs-s3.tls=true/d' "$services_snippet_file"
sed -i '/traefik.http.routers.rustfs-s3.tls.certresolver=default/d' "$services_snippet_file"
sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file"
fi
else
@@ -577,14 +596,14 @@ EOF
END { if (invol && !added) { print " redis:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
# Ensure minio-data if needed
if [[ $minio_storage == "y" ]]; then
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="minio-data:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
# Ensure rustfs-data if needed
if [[ $rustfs_storage == "y" ]]; then
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="rustfs-data:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " minio-data:"; print " driver: local"; added=1 } ; invol=0 }
invol && /^[^[:space:]]/ { if(!added){ print " rustfs-data:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " minio-data:"; print " driver: local" } }
END { if (invol && !added) { print " rustfs-data:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
fi
@@ -596,34 +615,34 @@ EOF
echo " driver: local"
echo " redis:"
echo " driver: local"
if [[ $minio_storage == "y" ]]; then
echo " minio-data:"
if [[ $rustfs_storage == "y" ]]; then
echo " rustfs-data:"
echo " driver: local"
fi
} >> docker-compose.yml
fi
# Create minio-init script outside heredoc to avoid variable expansion issues
if [[ $minio_storage == "y" ]]; then
cat > minio-init.sh << 'MINIO_SCRIPT_EOF'
# Create rustfs-init script outside heredoc to avoid variable expansion issues
if [[ $rustfs_storage == "y" ]]; then
cat > rustfs-init.sh << 'RUSTFS_SCRIPT_EOF'
#!/bin/sh
echo '⏳ Waiting for MinIO to be ready...'
echo '⏳ Waiting for RustFS to be ready...'
attempts=0
max_attempts=30
until mc alias set minio http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1 \
&& mc ls minio >/dev/null 2>&1; do
until mc alias set rustfs http://rustfs:9000 "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD" >/dev/null 2>&1 \
&& mc ls rustfs >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ $attempts -ge $max_attempts ]; then
printf '❌ Failed to connect to MinIO after %s attempts\n' $max_attempts
printf '❌ Failed to connect to RustFS after %s attempts\n' $max_attempts
exit 1
fi
printf '...still waiting attempt %s/%s\n' $attempts $max_attempts
sleep 2
done
echo '🔗 MinIO reachable; alias configured.'
echo '🔗 RustFS reachable; alias configured.'
echo '🪣 Creating bucket (idempotent)...';
mc mb minio/$MINIO_BUCKET_NAME --ignore-existing;
mc mb rustfs/$RUSTFS_BUCKET_NAME --ignore-existing;
echo '📄 Creating JSON policy file...';
cat > /tmp/formbricks-policy.json << EOF
@@ -633,40 +652,40 @@ cat > /tmp/formbricks-policy.json << EOF
{
"Effect": "Allow",
"Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME/*"]
"Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME/*"]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME"]
"Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME"]
}
]
}
EOF
echo '🔒 Creating policy (idempotent)...';
if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then
mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json || mc admin policy add minio formbricks-policy /tmp/formbricks-policy.json;
if ! mc admin policy info rustfs "$RUSTFS_POLICY_NAME" >/dev/null 2>&1; then
mc admin policy create rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json || mc admin policy add rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json;
echo 'Policy created successfully.';
else
echo 'Policy already exists, skipping creation.';
fi
echo '👤 Creating service user (idempotent)...';
if ! mc admin user info minio "$MINIO_SERVICE_USER" >/dev/null 2>&1; then
mc admin user add minio "$MINIO_SERVICE_USER" "$MINIO_SERVICE_PASSWORD";
if ! mc admin user info rustfs "$RUSTFS_SERVICE_USER" >/dev/null 2>&1; then
mc admin user add rustfs "$RUSTFS_SERVICE_USER" "$RUSTFS_SERVICE_PASSWORD";
echo 'User created successfully.';
else
echo 'User already exists, skipping creation.';
fi
echo '🔗 Attaching policy to user (idempotent)...';
mc admin policy attach minio formbricks-policy --user "$MINIO_SERVICE_USER" || echo 'Policy already attached or attachment failed (non-fatal).';
mc admin policy attach rustfs "$RUSTFS_POLICY_NAME" --user "$RUSTFS_SERVICE_USER" || echo 'Policy already attached or attachment failed (non-fatal).';
echo '✅ MinIO setup complete!';
echo '✅ RustFS setup complete!';
exit 0;
MINIO_SCRIPT_EOF
chmod +x minio-init.sh
RUSTFS_SCRIPT_EOF
chmod +x rustfs-init.sh
fi
newgrp docker <<END
@@ -678,10 +697,10 @@ echo "🔗 To edit more variables and deeper config, go to the formbricks/docker
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."
echo ""
if [[ $minio_storage == "y" ]]; then
echo "🗄️ MinIO Storage Setup Complete:"
echo " • Access Key: $minio_service_user (least privilege)"
echo " • Bucket: $minio_bucket_name (✅ created and secured)"
if [[ $rustfs_storage == "y" ]]; then
echo "🗄️ RustFS Storage Setup Complete:"
echo " • Access Key: $rustfs_service_user (least privilege)"
echo " • Bucket: $rustfs_bucket_name (✅ created and secured)"
echo ""
fi
@@ -736,38 +755,47 @@ get_logs() {
sudo docker compose logs
}
cleanup_minio_init() {
echo "🧹 Cleaning up MinIO init service and references..."
cleanup_rustfs_init() {
echo "🧹 Cleaning up RustFS init service and references..."
cd formbricks
# Remove minio-init service block from docker-compose.yml
# Remove rustfs-init service block from docker-compose.yml
awk '
BEGIN{skip=0}
/^services:[[:space:]]*$/ { print; next }
/^ minio-init:/ { skip=1; next }
/^ rustfs-init:/ { skip=1; next }
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Remove list-style "- minio-init" lines under depends_on (if any)
# Remove list-style init dependencies under depends_on (if any)
if sed --version >/dev/null 2>&1; then
sed -E -i '/^[[:space:]]*-[[:space:]]*rustfs-init[[:space:]]*$/d' docker-compose.yml
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
else
sed -E -i '' '/^[[:space:]]*-[[:space:]]*rustfs-init[[:space:]]*$/d' docker-compose.yml
sed -E -i '' '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
fi
# Remove the minio-init mapping and its condition line (mapping style depends_on)
# Remove the mapping style depends_on entries for init jobs
if sed --version >/dev/null 2>&1; then
sed -i '/^[[:space:]]*rustfs-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
else
sed -i '' '/^[[:space:]]*rustfs-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
sed -i '' '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
fi
# Remove any stopped minio-init container and restart without orphans
# Remove any stopped init containers and restart without orphans
docker compose rm -f -s rustfs-init >/dev/null 2>&1 || true
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
docker compose up -d --remove-orphans
echo "✅ MinIO init cleanup complete."
echo "✅ RustFS init cleanup complete."
}
cleanup_minio_init() {
cleanup_rustfs_init
}
case "$1" in
@@ -786,9 +814,12 @@ restart)
logs)
get_logs
;;
cleanup-minio-init)
cleanup_minio_init
;;
cleanup-rustfs-init)
cleanup_rustfs_init
;;
cleanup-minio-init)
cleanup_minio_init
;;
uninstall)
uninstall_formbricks
;;
@@ -796,4 +827,4 @@ uninstall)
echo "🚀 Executing default step of installing Formbricks"
install_formbricks
;;
esac
esac

757
docker/migrate-to-rustfs.sh Executable file
View File

@@ -0,0 +1,757 @@
#!/bin/env bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
MC_IMAGE="minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868"
RUSTFS_IMAGE="rustfs/rustfs:1.0.0-alpha.93"
print_status() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_info() {
echo -e "${BLUE} $1${NC}"
}
compose_network_flag() {
local name
name="$(docker network ls --format '{{.Name}}' | awk -v d="$(basename "$PWD")" '$0==d"_default"{print; exit}')" || true
if [[ -n "$name" ]]; then
echo --network "$name"
fi
}
read_compose_value() {
local key="$1"
local val
val=$(sed -n "s/^[[:space:]]*$key:[[:space:]]*\"\(.*\)\"[[:space:]]*$/\1/p" docker-compose.yml | head -n 1)
if [[ -z "$val" ]]; then
val=$(sed -n "s/^[[:space:]]*$key:[[:space:]]*\([^#][^[:space:]]*\)[[:space:]]*$/\1/p" docker-compose.yml | head -n 1)
fi
echo "$val"
}
has_service() {
grep -q "^ $1:[[:space:]]*$" docker-compose.yml
}
check_formbricks_directory() {
if [[ -f "docker-compose.yml" ]]; then
if grep -q "formbricks" docker-compose.yml; then
return 0
fi
print_error "This doesn't appear to be a Formbricks docker-compose.yml file."
exit 1
fi
if [[ -f "formbricks/docker-compose.yml" ]]; then
cd formbricks
print_status "Detected one-click setup layout. Switched to ./formbricks directory."
if ! grep -q "formbricks" docker-compose.yml; then
print_error "This doesn't appear to be a Formbricks docker-compose.yml file."
exit 1
fi
return 0
fi
print_error "docker-compose.yml not found in the current directory or ./formbricks/"
exit 1
}
backup_docker_compose() {
local backup_file="docker-compose.yml.backup.$(date +%Y%m%d_%H%M%S)"
cp docker-compose.yml "$backup_file"
print_status "Backed up docker-compose.yml to $backup_file"
}
detect_https_setup() {
if grep -q "websecure" docker-compose.yml || grep -q "certresolver" docker-compose.yml; then
echo "y"
else
echo "n"
fi
}
get_main_domain() {
local domain=""
if grep -q "WEBAPP_URL:" docker-compose.yml; then
domain=$(grep "WEBAPP_URL:" docker-compose.yml | sed 's/.*WEBAPP_URL: *"\([^"]*\)".*/\1/' 2>/dev/null)
if [[ -z "$domain" ]]; then
domain=$(grep "WEBAPP_URL:" docker-compose.yml | sed 's/.*WEBAPP_URL: *\([^[:space:]]*\).*/\1/')
fi
domain=$(echo "$domain" | sed -e 's|https://||' -e 's|http://||')
fi
echo "$domain"
}
add_or_replace_env_var() {
local key="$1"
local value="$2"
if grep -q "^[[:space:]]*$key:" docker-compose.yml; then
if sed --version >/dev/null 2>&1; then
sed -i "s|^\([[:space:]]*$key:\).*|\1 \"$value\"|" docker-compose.yml
else
sed -i '' "s|^\([[:space:]]*$key:\).*|\1 \"$value\"|" docker-compose.yml
fi
elif grep -q "^[[:space:]]*#[[:space:]]*$key:" docker-compose.yml; then
if sed --version >/dev/null 2>&1; then
sed -i "s|^[[:space:]]*#[[:space:]]*$key:.*| $key: \"$value\"|" docker-compose.yml
else
sed -i '' "s|^[[:space:]]*#[[:space:]]*$key:.*| $key: \"$value\"|" docker-compose.yml
fi
else
awk -v insert_key="$key" -v insert_val="$value" '
/^ environment:/ {print; in_env=1; next}
in_env && /^[^[:space:]]/ {
if (!printed) { print " " insert_key ": \"" insert_val "\""; printed=1 }
in_env=0
}
{ print }
END { if(in_env && !printed) print " " insert_key ": \"" insert_val "\"" }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
}
external_s3_guard() {
local acc sec
acc=$(read_compose_value "S3_ACCESS_KEY")
sec=$(read_compose_value "S3_SECRET_KEY")
if has_service minio; then
print_warning "Detected bundled MinIO in docker-compose.yml."
print_error "This helper does not migrate MinIO-backed installs to RustFS."
print_info "Use this script only for setups that still store uploads locally."
exit 0
fi
if [[ -n "$acc" && -n "$sec" ]] && ! has_service rustfs; then
print_warning "Detected existing S3 credentials without bundled RustFS."
print_error "This helper is intended only for local-upload installs."
print_info "If you already use AWS S3 or another S3-compatible provider, no migration is needed."
exit 0
fi
}
generate_rustfs_credentials() {
local existing_s3_access existing_s3_secret existing_bucket existing_endpoint
local existing_admin_user existing_admin_password
existing_s3_access=$(read_compose_value "S3_ACCESS_KEY")
existing_s3_secret=$(read_compose_value "S3_SECRET_KEY")
existing_bucket=$(read_compose_value "S3_BUCKET_NAME")
existing_endpoint=$(read_compose_value "S3_ENDPOINT_URL")
existing_admin_user=$(read_compose_value "RUSTFS_ACCESS_KEY")
existing_admin_password=$(read_compose_value "RUSTFS_SECRET_KEY")
rustfs_service_user="${existing_s3_access:-formbricks-service-$(openssl rand -hex 4)}"
rustfs_service_password="${existing_s3_secret:-$(openssl rand -base64 20)}"
rustfs_bucket_name="${existing_bucket:-formbricks-uploads}"
rustfs_policy_name="formbricks-policy"
rustfs_admin_user="${existing_admin_user:-formbricks-$(openssl rand -hex 4)}"
rustfs_admin_password="${existing_admin_password:-$(openssl rand -base64 20)}"
rustfs_existing_endpoint="$existing_endpoint"
}
add_rustfs_environment_variables() {
local files_domain="$1"
local https_setup="$2"
local s3_endpoint_url=""
if [[ "$https_setup" == "y" ]]; then
s3_endpoint_url="https://$files_domain"
else
s3_endpoint_url="http://$files_domain"
fi
add_or_replace_env_var "S3_ACCESS_KEY" "$rustfs_service_user"
add_or_replace_env_var "S3_SECRET_KEY" "$rustfs_service_password"
add_or_replace_env_var "S3_REGION" "us-east-1"
add_or_replace_env_var "S3_BUCKET_NAME" "$rustfs_bucket_name"
add_or_replace_env_var "S3_ENDPOINT_URL" "$s3_endpoint_url"
add_or_replace_env_var "S3_FORCE_PATH_STYLE" "1"
print_status "S3 environment variables ensured in docker-compose.yml"
}
add_rustfs_services() {
local files_domain="$1"
local main_domain="$2"
local https_setup="$3"
if has_service rustfs && has_service rustfs-init && has_service rustfs-perms; then
print_info "RustFS services already present. Skipping service injection."
return 0
fi
local traefik_entrypoints cors_origin tls_block
if [[ "$https_setup" == "y" ]]; then
traefik_entrypoints="websecure"
cors_origin="https://$main_domain"
tls_block=$' - "traefik.http.routers.rustfs-s3.tls=true"\n - "traefik.http.routers.rustfs-s3.tls.certresolver=default"'
else
traefik_entrypoints="web"
cors_origin="http://$main_domain"
tls_block=""
fi
cat > rustfs_service.tmp <<EOF
rustfs-perms:
image: busybox:1.36.1
user: "0:0"
command: ["sh", "-c", "mkdir -p /data && chown -R 10001:10001 /data"]
volumes:
- rustfs-data:/data
rustfs:
restart: always
image: $RUSTFS_IMAGE
depends_on:
rustfs-perms:
condition: service_completed_successfully
command: /data
environment:
RUSTFS_ACCESS_KEY: "$rustfs_admin_user"
RUSTFS_SECRET_KEY: "$rustfs_admin_password"
RUSTFS_ADDRESS: ":9000"
volumes:
- rustfs-data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.rustfs-s3.rule=Host(\`$files_domain\`)"
- "traefik.http.routers.rustfs-s3.entrypoints=$traefik_entrypoints"
$tls_block
- "traefik.http.routers.rustfs-s3.service=rustfs-s3"
- "traefik.http.services.rustfs-s3.loadbalancer.server.port=9000"
- "traefik.http.routers.rustfs-s3.middlewares=rustfs-cors,rustfs-ratelimit"
- "traefik.http.middlewares.rustfs-cors.headers.accesscontrolallowmethods=GET,PUT,POST,DELETE,HEAD,OPTIONS"
- "traefik.http.middlewares.rustfs-cors.headers.accesscontrolallowheaders=*"
- "traefik.http.middlewares.rustfs-cors.headers.accesscontrolalloworiginlist=$cors_origin"
- "traefik.http.middlewares.rustfs-cors.headers.accesscontrolmaxage=100"
- "traefik.http.middlewares.rustfs-cors.headers.addvaryheader=true"
- "traefik.http.middlewares.rustfs-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.rustfs-ratelimit.ratelimit.burst=200"
rustfs-init:
image: $MC_IMAGE
depends_on:
- rustfs
environment:
RUSTFS_ADMIN_USER: "$rustfs_admin_user"
RUSTFS_ADMIN_PASSWORD: "$rustfs_admin_password"
RUSTFS_SERVICE_USER: "$rustfs_service_user"
RUSTFS_SERVICE_PASSWORD: "$rustfs_service_password"
RUSTFS_BUCKET_NAME: "$rustfs_bucket_name"
RUSTFS_POLICY_NAME: "$rustfs_policy_name"
entrypoint:
- /bin/sh
- -c
- |
attempts=0
max_attempts=30
until mc alias set rustfs http://rustfs:9000 "$rustfs_admin_user" "$rustfs_admin_password" >/dev/null 2>&1 \
&& mc ls rustfs >/dev/null 2>&1; do
attempts=\$((attempts + 1))
if [ "\$attempts" -ge "\$max_attempts" ]; then
echo "RustFS did not become ready within \${max_attempts} attempts"
exit 1
fi
sleep 2
done
mc mb rustfs/$rustfs_bucket_name --ignore-existing
cat > /tmp/formbricks-policy.json << 'POLICY_EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::$rustfs_bucket_name/*"]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::$rustfs_bucket_name"]
}
]
}
POLICY_EOF
if ! mc admin policy info rustfs $rustfs_policy_name >/dev/null 2>&1; then
mc admin policy create rustfs $rustfs_policy_name /tmp/formbricks-policy.json || mc admin policy add rustfs $rustfs_policy_name /tmp/formbricks-policy.json
fi
if ! mc admin user info rustfs "$rustfs_service_user" >/dev/null 2>&1; then
mc admin user add rustfs "$rustfs_service_user" "$rustfs_service_password"
fi
mc admin policy attach rustfs $rustfs_policy_name --user "$rustfs_service_user" || true
EOF
awk '
{
print
if ($0 ~ /^services:$/ && !inserted) {
while ((getline line < "rustfs_service.tmp") > 0) print line
close("rustfs_service.tmp")
inserted = 1
}
}
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
rm -f rustfs_service.tmp
print_status "Added RustFS services to docker-compose.yml"
}
add_rustfs_volume() {
if grep -q '^volumes:' docker-compose.yml; then
if awk '/^volumes:/{invol=1; next} invol && NF==0{invol=0} invol{ if($1=="rustfs-data:") found=1 } END{ exit(!found) }' docker-compose.yml; then
print_info "rustfs-data volume already present."
else
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " rustfs-data:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " rustfs-data:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
print_status "rustfs-data volume ensured"
fi
else
{
echo ""
echo "volumes:"
echo " rustfs-data:"
echo " driver: local"
} >> docker-compose.yml
print_status "Added volumes section with rustfs-data"
fi
}
wait_for_rustfs_ready() {
print_info "Waiting for RustFS to be ready..."
local max_attempts=30
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
if docker run --rm $(compose_network_flag) --entrypoint /bin/sh "$MC_IMAGE" -lc \
"mc alias set rustfs http://rustfs:9000 '$rustfs_admin_user' '$rustfs_admin_password' >/dev/null 2>&1 && mc admin info rustfs >/dev/null 2>&1"; then
print_status "RustFS is ready!"
return 0
fi
if [[ $attempt -eq $max_attempts ]]; then
print_error "RustFS did not become ready within the expected time."
return 1
fi
sleep 5
((attempt++))
done
}
ensure_bucket_exists() {
print_info "Ensuring bucket '$rustfs_bucket_name' exists..."
docker run --rm $(compose_network_flag) \
-e RUSTFS_ADMIN_USER="$rustfs_admin_user" \
-e RUSTFS_ADMIN_PASSWORD="$rustfs_admin_password" \
-e RUSTFS_BUCKET_NAME="$rustfs_bucket_name" \
--entrypoint /bin/sh "$MC_IMAGE" -lc '
mc alias set rustfs http://rustfs:9000 "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD" >/dev/null 2>&1
mc mb rustfs/"$RUSTFS_BUCKET_NAME" --ignore-existing
'
}
ensure_service_user_and_policy() {
print_info "Ensuring RustFS service user and policy exist..."
docker run --rm $(compose_network_flag) \
-e RUSTFS_ADMIN_USER="$rustfs_admin_user" \
-e RUSTFS_ADMIN_PASSWORD="$rustfs_admin_password" \
-e RUSTFS_SERVICE_USER="$rustfs_service_user" \
-e RUSTFS_SERVICE_PASSWORD="$rustfs_service_password" \
-e RUSTFS_BUCKET_NAME="$rustfs_bucket_name" \
-e RUSTFS_POLICY_NAME="$rustfs_policy_name" \
--entrypoint /bin/sh "$MC_IMAGE" -lc '
mc alias set rustfs http://rustfs:9000 "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD" >/dev/null 2>&1
if ! mc admin policy info rustfs "$RUSTFS_POLICY_NAME" >/dev/null 2>&1; then
cat > /tmp/formbricks-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], "Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME/*"] },
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME"] }
]
}
EOF
mc admin policy create rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json >/dev/null 2>&1 || mc admin policy add rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json >/dev/null 2>&1
fi
if ! mc admin user info rustfs "$RUSTFS_SERVICE_USER" >/dev/null 2>&1; then
mc admin user add rustfs "$RUSTFS_SERVICE_USER" "$RUSTFS_SERVICE_PASSWORD" >/dev/null 2>&1 || true
fi
mc admin policy attach rustfs "$RUSTFS_POLICY_NAME" --user "$RUSTFS_SERVICE_USER" >/dev/null 2>&1 || true
'
}
wait_for_service_up() {
local service_name="$1"
local max_attempts=30
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
if [[ -n "$(docker compose ps --status running -q "$service_name" 2>/dev/null)" ]]; then
print_status "$service_name is running."
return 0
fi
if [[ $attempt -eq $max_attempts ]]; then
print_error "$service_name did not become ready within the expected time."
return 1
fi
sleep 5
((attempt++))
done
}
collect_upload_sources_post_start() {
declare -A seen
local out=()
local env_path
env_path=$(read_compose_value "UPLOADS_DIR")
local container_present=0
local awk_tmp
awk_tmp=$(mktemp)
cat > "$awk_tmp" <<'AWK_EOF'
/^ formbricks:/ { in_svc=1; next }
/^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ { in_svc=0 }
in_svc && /^ volumes:/ { in_vol=1; next }
in_svc && /^ [A-Za-z0-9_-]+:/ { if(in_vol) in_vol=0 }
in_vol {
if ($0 ~ /^[[:space:]]*-[[:space:]]*type:/){ tp=$0; sub(/.*type:[[:space:]]*/, "", tp); src=""; tgt="" }
else if ($0 ~ /source:/){ line=$0; sub(/^[^:]*:[[:space:]]*/, "", line); src=line }
else if ($0 ~ /target:/){ line=$0; sub(/^[^:]*:[[:space:]]*/, "", line); tgt=line; if (tp!="" && tgt!="") printf "%s|%s|%s\n", tp, src, tgt }
}
AWK_EOF
local entries
entries=$(docker compose config 2>/dev/null | awk -f "$awk_tmp")
rm -f "$awk_tmp"
while IFS= read -r e; do
[[ -z "$e" ]] && continue
local tp src tgt rest
tp="${e%%|*}"
rest="${e#*|}"
src="${rest%%|*}"
tgt="${rest##*|}"
if [[ "$tp" == "bind" && "$tgt" == *"/uploads"* ]]; then
if [[ -z "${seen[$src]}" ]]; then
out+=("$src")
seen[$src]=1
fi
fi
if [[ "$tp" == "volume" && "$src" == "uploads" ]]; then
local key="container:$tgt"
if [[ -z "${seen[$key]}" ]]; then
out+=("$key")
seen[$key]=1
fi
container_present=1
fi
done <<< "$entries"
if [[ -n "$env_path" && "$env_path" =~ ^/ ]]; then
local key="container:$env_path"
if [[ -z "${seen[$key]}" ]]; then
out+=("$key")
seen[$key]=1
fi
container_present=1
elif [[ -n "$env_path" && -d "$env_path" && -z "${seen[$env_path]}" ]]; then
out+=("$env_path")
seen[$env_path]=1
elif [[ -n "$env_path" && -d "./$env_path" && -z "${seen[./$env_path]}" ]]; then
out+=("./$env_path")
seen["./$env_path"]=1
fi
if [[ $container_present -eq 0 ]]; then
local legacy="container:/home/nextjs/apps/web/uploads"
if [[ -z "${seen[$legacy]}" ]]; then
out+=("$legacy")
seen[$legacy]=1
fi
fi
if [[ -d "./apps/web/uploads" && -z "${seen[./apps/web/uploads]}" ]]; then
out+=("./apps/web/uploads")
seen[./apps/web/uploads]=1
fi
if [[ -d "./uploads" && -z "${seen[./uploads]}" ]]; then
out+=("./uploads")
seen[./uploads]=1
fi
for s in "${out[@]}"; do
echo "$s"
done
}
preview_upload_sources() {
local sources="$1"
echo ""
echo "📋 Migration sources preview:"
while IFS= read -r src; do
[[ -z "$src" ]] && continue
if [[ "$src" == container:* ]]; then
local p="${src#container:}"
local cnt
cnt=$(docker compose exec -T formbricks sh -lc 'find '"$p"' -type f 2>/dev/null | wc -l' || echo 0)
echo " - $src → $cnt files"
else
local cnt
cnt=$(find "$src" -type f 2>/dev/null | wc -l || echo 0)
echo " - $src (host) → $cnt files"
fi
done <<< "$sources"
}
migrate_container_files_to_rustfs() {
local container_path="$1"
local file_count
local formbricks_cid
if ! docker compose exec -T formbricks test -d "$container_path" 2>/dev/null; then
print_warning "Container path not found, skipping: $container_path"
return 0
fi
file_count=$(docker compose exec -T formbricks find "$container_path" -type f 2>/dev/null | wc -l)
if [[ $file_count -eq 0 ]]; then
print_warning "No files found in $container_path to migrate."
return 0
fi
formbricks_cid=$(docker compose ps -q formbricks)
docker run --rm $(compose_network_flag) \
--volumes-from "$formbricks_cid" \
-e RUSTFS_ADMIN_USER="$rustfs_admin_user" \
-e RUSTFS_ADMIN_PASSWORD="$rustfs_admin_password" \
-e RUSTFS_BUCKET_NAME="$rustfs_bucket_name" \
--entrypoint /bin/sh "$MC_IMAGE" -lc '
mc alias set rustfs http://rustfs:9000 "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD"
mc mirror --overwrite --preserve '"$container_path"' "rustfs/$RUSTFS_BUCKET_NAME"
'
print_status "Migrated $file_count files from $container_path"
}
migrate_host_files_to_rustfs() {
local uploads_dir="$1"
local file_count
local host_src="$uploads_dir"
if [[ ! -d "$uploads_dir" ]]; then
print_warning "Host path not found, skipping: $uploads_dir"
return 0
fi
file_count=$(find "$uploads_dir" -type f 2>/dev/null | wc -l)
if [[ $file_count -eq 0 ]]; then
print_warning "No files found in $uploads_dir to migrate."
return 0
fi
if [[ "$host_src" != /* ]]; then
host_src="$PWD/$host_src"
fi
docker run --rm $(compose_network_flag) \
-v "$host_src:/source:ro" \
-e RUSTFS_ADMIN_USER="$rustfs_admin_user" \
-e RUSTFS_ADMIN_PASSWORD="$rustfs_admin_password" \
-e RUSTFS_BUCKET_NAME="$rustfs_bucket_name" \
--entrypoint /bin/sh "$MC_IMAGE" -lc '
mc alias set rustfs http://rustfs:9000 "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD"
mc mirror --overwrite --preserve /source "rustfs/$RUSTFS_BUCKET_NAME"
'
print_status "Migrated $file_count files from $uploads_dir"
}
cleanup_uploads_from_compose() {
print_info "Cleaning docker-compose.yml uploads configuration..."
if sed --version >/dev/null 2>&1; then
sed -i 's/^\([[:space:]]*\)UPLOADS_DIR:[[:space:]].*/\1# UPLOADS_DIR:/' docker-compose.yml || true
else
sed -i '' 's/^\([[:space:]]*\)UPLOADS_DIR:[[:space:]].*/\1# UPLOADS_DIR:/' docker-compose.yml || true
fi
awk '
BEGIN{in_svc=0; in_vol=0}
/^ formbricks:/ {in_svc=1}
/^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ {in_svc=0}
{line=$0}
in_svc && /^ volumes:/ {in_vol=1; print; next}
in_svc && /^ [A-Za-z0-9_-]+:/ {if(in_vol) in_vol=0}
in_vol {
if (line ~ /^[[:space:]]*-[[:space:]]*uploads:/) next
if (line ~ /^[[:space:]]*-[[:space:]].*\/uploads\/?[[:space:]]*$/) next
print
next
}
{print}
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
awk '
BEGIN{in_vol=0}
/^volumes:[[:space:]]*$/ {print; in_vol=1; next}
in_vol && /^ uploads:[[:space:]]*$/ {skip=1; next}
in_vol && skip && /^[[:space:]]{4}[A-Za-z0-9_-]+:/ {next}
in_vol && skip && /^ [A-Za-z0-9_-]+:/ {skip=0}
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
print_status "Removed legacy local uploads configuration from docker-compose.yml"
}
migrate_to_rustfs() {
echo "🦀 Formbricks RustFS Migration"
echo "=============================="
echo ""
print_info "This helper provisions bundled RustFS and migrates legacy local uploads."
print_info "MinIO-backed installs are intentionally skipped."
echo ""
check_formbricks_directory
backup_docker_compose
external_s3_guard
local main_domain
local https_setup
local files_domain
main_domain=$(get_main_domain)
https_setup=$(detect_https_setup)
if [[ -z "$main_domain" ]]; then
print_error "Could not detect WEBAPP_URL from docker-compose.yml"
exit 1
fi
generate_rustfs_credentials
if has_service rustfs; then
files_domain=$(echo "$rustfs_existing_endpoint" | sed -E 's#^https?://##')
if [[ -z "$files_domain" ]]; then
files_domain="files.$main_domain"
fi
print_info "Detected existing RustFS configuration for $files_domain"
else
print_warning "RustFS requires a dedicated files subdomain."
print_info "Make sure files.$main_domain points to the same server as your Formbricks instance."
echo -n "Do you want to continue? [Y/n]: "
read -r continue_setup
continue_setup=$(echo "$continue_setup" | tr '[:upper:]' '[:lower:]')
if [[ -n "$continue_setup" && "$continue_setup" != "y" ]]; then
print_info "Migration cancelled."
exit 0
fi
local default_files_domain="files.$main_domain"
echo -n "Enter the files subdomain to use for RustFS (e.g., $default_files_domain): "
read -r files_domain
if [[ -z "$files_domain" ]]; then
files_domain="$default_files_domain"
fi
add_rustfs_environment_variables "$files_domain" "$https_setup"
add_rustfs_services "$files_domain" "$main_domain" "$https_setup"
add_rustfs_volume
fi
echo ""
echo -n "Restart Docker Compose now to provision RustFS and continue the migration? [Y/n]: "
read -r restart_confirm
restart_confirm=$(echo "$restart_confirm" | tr '[:upper:]' '[:lower:]')
if [[ -n "$restart_confirm" && "$restart_confirm" != "y" ]]; then
print_warning "Migration cancelled before any file copy."
print_info "Run 'docker compose up -d --remove-orphans' later, then rerun this script."
exit 0
fi
docker compose up -d --remove-orphans
wait_for_service_up formbricks
wait_for_rustfs_ready
ensure_service_user_and_policy
ensure_bucket_exists
local sources
sources=$(collect_upload_sources_post_start)
preview_upload_sources "$sources"
if [[ -z "$sources" ]]; then
echo -n "No upload sources were detected automatically. Enter a path (container:/path or ./path), or press Enter to skip: "
read -r manual_src
manual_src=$(echo "$manual_src" | xargs)
if [[ -n "$manual_src" ]]; then
sources="$manual_src"
fi
fi
if [[ -z "$sources" ]]; then
print_warning "No uploads directory was detected."
cleanup_uploads_from_compose
print_status "RustFS is configured. No local files were found to migrate."
else
echo -n "Proceed with the migration from the sources above? [Y/n]: "
read -r do_migration
do_migration=$(echo "$do_migration" | tr '[:upper:]' '[:lower:]')
if [[ -n "$do_migration" && "$do_migration" != "y" ]]; then
print_warning "Skipped file migration at your request."
exit 0
fi
local migration_failed=0
while IFS= read -r src; do
[[ -z "$src" ]] && continue
if [[ "$src" == container:* ]]; then
migrate_container_files_to_rustfs "${src#container:}" || migration_failed=1
else
migrate_host_files_to_rustfs "$src" || migration_failed=1
fi
done <<< "$sources"
if [[ $migration_failed -eq 0 ]]; then
cleanup_uploads_from_compose
print_status "Local uploads have been migrated to RustFS."
else
print_error "Migration failed before cleanup."
print_info "Legacy upload paths were left untouched so you can retry safely."
exit 1
fi
fi
echo ""
print_status "RustFS migration complete."
print_info "Files domain: $files_domain"
print_info "S3 Access Key: $rustfs_service_user"
print_info "S3 Bucket: $rustfs_bucket_name"
print_info "RustFS admin user: $rustfs_admin_user"
print_info "Cleanup note: keep rustfs-init if you want idempotent bootstrap on future restarts."
}
migrate_to_rustfs "$@"

View File

@@ -39,6 +39,7 @@ When Formbricks v4.7 starts for the first time, the data migration will:
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Info>
</Tab>
<Tab title="Kubernetes">
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
@@ -52,6 +53,7 @@ When Formbricks v4.7 starts for the first time, the data migration will:
</Info>
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
</Tab>
</Tabs>
@@ -69,6 +71,7 @@ When Formbricks v4.7 starts for the first time, the data migration will:
# Start with Formbricks v4.7
docker compose up -d
```
</Tab>
<Tab title="Kubernetes">
```bash
@@ -81,6 +84,7 @@ When Formbricks v4.7 starts for the first time, the data migration will:
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
PreSync hook before the new pods start. No manual migration step is needed.
</Info>
</Tab>
</Tabs>
@@ -105,6 +109,7 @@ After Formbricks starts, check the logs to see whether the value backfill was co
```bash
kubectl logs -n formbricks job/formbricks-migration
```
</Tab>
</Tabs>
@@ -121,6 +126,7 @@ If the migration skipped the value backfill, run the standalone backfill script
```
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
</Tab>
<Tab title="Kubernetes">
```bash
@@ -130,6 +136,7 @@ If the migration skipped the value backfill, run the standalone backfill script
<Info>
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
</Info>
</Tab>
</Tabs>
@@ -202,7 +209,7 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
These improvements in Formbricks 4.0 also make some infrastructure requirements mandatory going forward:
- **Redis** for caching
- **MinIO or S3-compatible storage** for file uploads
- **RustFS or S3-compatible storage** for file uploads
These services are already included in the updated one-click setup for self-hosters, but existing users need to upgrade their setup. More information on this below.
@@ -223,9 +230,43 @@ We believe this is the only path forward to build the comprehensive Survey and E
Additional migration steps are needed if you are using a self-hosted Formbricks setup that uses either local file storage (not S3-compatible file storage) or doesn't already use a Redis cache.
### One-Click Setup
### Local uploads to bundled RustFS
For users using our official one-click setup, we provide an automated migration using a migration script:
If your current self-hosted setup still stores uploads on the local filesystem and you now want to move to the
bundled object storage setup, use the RustFS migration helper:
```bash
# Download the latest script
curl -fsSL -o migrate-to-rustfs.sh \
https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/migrate-to-rustfs.sh
# Make it executable
chmod +x migrate-to-rustfs.sh
# Launch the guided migration
./migrate-to-rustfs.sh
```
This helper:
- Adds bundled RustFS services to your Compose setup
- Configures the bucket, policy, and least-privilege service credentials
- Copies legacy local uploads into the RustFS bucket
- Removes legacy local upload configuration only after the copy succeeds
<Note>
This helper targets a convenient single-server RustFS setup. If you need higher availability or a
larger-scale storage architecture, prefer external object storage or a dedicated RustFS deployment.
</Note>
<Info>
This helper does **not** migrate MinIO-backed installs to RustFS. It is designed only for setups that still
use local uploads.
</Info>
### Legacy one-click v4.0 upgrade
For historical v4.0 upgrades, our original one-click migration script is still available:
```bash
# Download the latest script
@@ -242,7 +283,7 @@ chmod +x migrate-to-v4.sh
This script guides you through the steps for the infrastructure migration and does the following:
- Adds a Redis service to your setup and configures it
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
- Adds a bundled MinIO service to your setup, configures it, and migrates local files to it
- Pulls the latest Formbricks image and updates your instance
### Manual Setup
@@ -264,7 +305,7 @@ Formbricks supports multiple storage providers (among many other S3-compatible s
- AWS S3
- Digital Ocean Spaces
- Hetzner Object Storage
- Custom MinIO server
- Custom RustFS server
Please make sure to set up a storage bucket with one of these solutions and then link it to Formbricks using the following environment variables:
@@ -273,7 +314,8 @@ Please make sure to set up a storage bucket with one of these solutions and then
S3_SECRET_KEY: your-secret-key
S3_REGION: us-east-1
S3_BUCKET_NAME: formbricks-uploads
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
S3_ENDPOINT_URL: https://files.yourdomain.com # not needed for AWS S3
S3_FORCE_PATH_STYLE: 1
```
#### Upgrade Process
@@ -319,7 +361,7 @@ No manual intervention is required for the database migration.
**4. Verify Your Upgrade**
- Access your Formbricks instance at the same URL as before
- Test file uploads to ensure S3/MinIO integration works correctly. Check the [File Upload Troubleshooting](/self-hosting/configuration/file-uploads#troubleshooting) section if you face any issues.
- Test file uploads to ensure S3 storage integration works correctly. Check the [File Upload Troubleshooting](/self-hosting/configuration/file-uploads#troubleshooting) section if you face any issues.
- Verify that existing surveys and data are intact
- Check that previously uploaded files are accessible

View File

@@ -4,7 +4,7 @@ description: "Configure file storage for survey images, file uploads, and projec
icon: "upload"
---
Formbricks requires S3-compatible storage for file uploads. You can use external cloud storage services or the bundled MinIO option for a self-hosted solution.
Formbricks requires S3-compatible storage for file uploads. You can use external cloud storage services or the bundled RustFS option for a self-hosted solution.
## Why Configure File Uploads?
@@ -35,18 +35,25 @@ Use cloud storage services for production deployments:
- **StorJ**
- Any S3-compatible storage service
### 2. Bundled MinIO Storage (Self-Hosted)
### 2. Bundled RustFS Storage (Self-Hosted)
<Warning>
**Important**: MinIO requires a dedicated subdomain to function properly. You must configure a subdomain
like `files.yourdomain.com` that points to your server. MinIO will not work without this subdomain setup.
**Important**: Bundled RustFS requires a dedicated subdomain. You must configure a subdomain like
`files.yourdomain.com` that points to your server so browser uploads can reach the object storage endpoint.
</Warning>
MinIO provides a self-hosted S3-compatible storage solution that runs alongside Formbricks. This option:
<Warning>
Bundled RustFS is a convenience-oriented single-server option. It fits small-scale or lower-complexity
self-hosted deployments, but it is not the ideal RustFS architecture for high-availability or larger-scale
production storage. For stricter production requirements, prefer external object storage or a dedicated
RustFS deployment.
</Warning>
RustFS provides a self-hosted S3-compatible storage solution that runs alongside Formbricks. This option:
- Runs in a Docker container alongside Formbricks
- Provides full S3 API compatibility
- Requires minimal additional configuration
- Uses the same `S3_*` environment variables as any other S3-compatible provider
## Configuration Methods
@@ -78,14 +85,14 @@ Choose this option for AWS S3, DigitalOcean Spaces, or other cloud providers:
S3 Endpoint URL (leave empty if you are using AWS S3): https://your-endpoint.com
```
#### Bundled MinIO Storage
#### Bundled RustFS Storage
Choose this option for a self-hosted S3-compatible storage that runs alongside Formbricks:
<Note>
**Critical Requirement**: Before proceeding, ensure you have configured a subdomain (e.g.,
`files.yourdomain.com`) that points to your server's IP address. MinIO will not function without this
subdomain setup.
`files.yourdomain.com`) that points to your server's IP address. This is required so browser-direct uploads
can reach RustFS.
</Note>
```bash
@@ -95,7 +102,7 @@ Choose this option for a self-hosted S3-compatible storage that runs alongside F
The script will automatically:
- Generate secure MinIO credentials
- Generate separate RustFS admin and Formbricks service credentials
- Create the storage bucket
- Configure SSL certificates for the files subdomain
- Configure Traefik routing for the subdomain
@@ -122,7 +129,7 @@ S3_FORCE_PATH_STYLE=1
<Note>
<strong>AWS S3 vs. thirdparty S3:</strong> When using AWS S3 directly, leave `S3_ENDPOINT_URL` unset and
set `S3_FORCE_PATH_STYLE=0` (or omit). For most thirdparty S3compatible providers (e.g., MinIO,
set `S3_FORCE_PATH_STYLE=0` (or omit). For most thirdparty S3compatible providers (e.g., RustFS,
DigitalOcean Spaces, Wasabi, Storj), you typically must set `S3_ENDPOINT_URL` to the provider's endpoint and
set `S3_FORCE_PATH_STYLE=1`.
</Note>
@@ -151,11 +158,11 @@ S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
S3_FORCE_PATH_STYLE=1
```
### MinIO (Self-Hosted)
### RustFS (Self-Hosted)
```bash
S3_ACCESS_KEY=minio_access_key
S3_SECRET_KEY=minio_secret_key
S3_ACCESS_KEY=rustfs_service_access_key
S3_SECRET_KEY=rustfs_service_secret_key
S3_REGION=us-east-1
S3_BUCKET_NAME=formbricks-uploads
S3_ENDPOINT_URL=https://files.yourdomain.com
@@ -170,14 +177,14 @@ that do not implement POST Object are not compatible with Formbricks uploads. Fo
S3compatible API currently does not support POST Object and therefore will not work with Formbricks file
uploads.
## Bundled MinIO Setup
## Bundled RustFS Setup
When using the bundled MinIO option through the setup script, you get:
When using the bundled RustFS option through the setup script, you get:
### Automatic Configuration
- **Storage Service**: MinIO running in a Docker container
- **Credentials**: Auto-generated secure access keys
- **Storage Service**: RustFS running in a Docker container
- **Credentials**: Auto-generated admin and least-privilege service credentials
- **Bucket**: Automatically created `formbricks-uploads` bucket
- **SSL**: Automatic certificate generation for the files subdomain
@@ -186,23 +193,22 @@ When using the bundled MinIO option through the setup script, you get:
After setup, you'll see:
```bash
🗄️ MinIO Storage Setup Complete:
S3 API: https://files.yourdomain.com
• Access Key: formbricks-a1b2c3d4
🗄️ RustFS Storage Setup Complete:
Access Key: formbricks-service-a1b2c3d4
• Bucket: formbricks-uploads (✅ automatically created)
```
### DNS Requirements
<Warning>
**Critical for MinIO**: The subdomain configuration is mandatory for MinIO to function. Without proper
subdomain DNS setup, MinIO will fail to work entirely.
**Critical for bundled RustFS**: The files subdomain is mandatory. Without proper DNS and reverse-proxy
routing, browser uploads will fail.
</Warning>
For the bundled MinIO setup, ensure:
For the bundled RustFS setup, ensure:
1. **Main domain**: `yourdomain.com` points to your server IP
2. **Files subdomain**: `files.yourdomain.com` points to your server IP (this is required for MinIO to work)
2. **Files subdomain**: `files.yourdomain.com` points to your server IP
3. **Firewall**: Ports 80 and 443 are open in your server's firewall
4. **DNS propagation**: Allow time for DNS changes to propagate globally
@@ -234,23 +240,23 @@ services:
When using AWS S3 or S3-compatible storage providers, ensure that the IAM user associated with your `S3_ACCESS_KEY` and `S3_SECRET_KEY` credentials has the necessary permissions to interact with your bucket. Without proper permissions, file uploads and retrievals will fail.
The following IAM policy grants the minimum required permissions for Formbricks to function correctly. This policy is also used in the bundled MinIO integration:
The following IAM policy grants the minimum required permissions for Formbricks to function correctly. This policy is also used in the bundled RustFS integration:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"],
"Effect": "Allow",
"Resource": ["arn:aws:s3:::your-bucket-name/*"]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Effect": "Allow",
"Resource": ["arn:aws:s3:::your-bucket-name"]
}
]
],
"Version": "2012-10-17"
}
```
@@ -294,7 +300,9 @@ Example least-privileged S3 bucket policy:
```
<Note>
Replace `your-bucket-name` with your actual bucket name and `arn:aws:iam::123456789012:user/formbricks-service` with the ARN of your IAM user. This policy allows public read access only to specific paths while restricting write access to your Formbricks service user.
Replace `your-bucket-name` with your actual bucket name and
`arn:aws:iam::123456789012:user/formbricks-service` with the ARN of your IAM user. This policy allows public
read access only to specific paths while restricting write access to your Formbricks service user.
</Note>
### S3 CORS Configuration
@@ -306,30 +314,19 @@ Configure CORS on your S3 bucket with the following settings:
```json
[
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"POST",
"GET",
"HEAD",
"DELETE",
"PUT"
],
"AllowedOrigins": [
"*"
],
"ExposeHeaders": [
"ETag",
"x-amz-meta-custom-header"
],
"AllowedHeaders": ["*"],
"AllowedMethods": ["POST", "GET", "HEAD", "DELETE", "PUT"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag", "x-amz-meta-custom-header"],
"MaxAgeSeconds": 3000
}
]
```
<Note>
For production environments, consider restricting `AllowedOrigins` to your specific Formbricks domain(s) instead of using `"*"` for better security. For example: `["https://app.yourdomain.com", "https://yourdomain.com"]`.
For production environments, consider restricting `AllowedOrigins` to your specific Formbricks domain(s)
instead of using `"*"` for better security. For example: `["https://app.yourdomain.com",
"https://yourdomain.com"]`.
</Note>
**How to configure CORS:**
@@ -338,9 +335,9 @@ Configure CORS on your S3 bucket with the following settings:
- **DigitalOcean Spaces**: Navigate to your Space → Settings → CORS Configurations → Add CORS configuration → Paste the JSON
- **Other S3-compatible providers**: Refer to your provider's documentation for CORS configuration
### MinIO Security
### RustFS Security
When using bundled MinIO:
When using bundled RustFS:
- Credentials are auto-generated and secure
- Access is restricted through Traefik proxy
@@ -359,8 +356,8 @@ When using bundled MinIO:
3. Ensure bucket permissions allow uploads from your server
4. Check network connectivity to S3 endpoint
5. We use S3 presigned URLs for uploads. Make sure your CORS policy allows presigned URL uploads; otherwise, uploads will fail.
Some providers (e.g., Hetzners object storage) [require a specific CORS configuration](https://github.com/formbricks/formbricks/discussions/6641#discussioncomment-14574048).
If youre using the bundled MinIO setup, this is already configured for you.
Some providers (e.g., Hetzners object storage) [require a specific CORS configuration](https://github.com/formbricks/formbricks/discussions/6641#discussioncomment-14574048).
If youre using the bundled RustFS setup, this is already configured for you.
**Images not displaying in surveys:**
@@ -368,13 +365,13 @@ If youre using the bundled MinIO setup, this is already configured for you.
2. Check CORS configuration allows requests from your domain
3. Ensure S3_ENDPOINT_URL is correctly set for third-party services
**MinIO not starting:**
**RustFS not starting:**
1. **Verify subdomain DNS**: Ensure `files.yourdomain.com` points to your server IP (this is the most common issue)
2. **Check DNS propagation**: Use tools like `nslookup` or `dig` to verify DNS resolution
3. **Verify ports**: Ensure ports 80 and 443 are open in your firewall
4. **SSL certificate**: Check that SSL certificate generation completed successfully
5. **Container logs**: Check Docker container logs: `docker compose logs minio`
5. **Container logs**: Check Docker container logs: `docker compose logs rustfs`
### Testing Your Configuration
@@ -389,8 +386,8 @@ To test if file uploads are working:
# Check Formbricks logs
docker compose logs formbricks
# Check MinIO logs (if using bundled MinIO)
docker compose logs minio
# Check RustFS logs (if using bundled RustFS)
docker compose logs rustfs
```
For additional help, join the conversation on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).

View File

@@ -87,28 +87,24 @@ graph TD
### Component Description
1. **Formbricks Cluster**
- Multiple Formbricks instances (1..n) running in parallel
- Each instance is stateless and can handle any incoming request
- Automatic failover if any instance becomes unavailable
2. **PostgreSQL Database**
- Primary database storing all survey, response, and contact data
- Optional high-availability setup with primary-replica configuration
- Handles all persistent data storage needs
3. **Redis Cluster**
- Acts as a distributed cache layer
- Improves performance by caching frequently accessed data
- Can be configured in HA mode with primary-replica setup
- Handles session management and real-time features
4. **S3 Compatible Storage**
- Stores file uploads and attachments
- Can be any S3-compatible storage service (AWS S3, MinIO, etc.)
- Can be any S3-compatible storage service (AWS S3, RustFS, etc.)
- Provides reliable and scalable file storage
5. **Load Balancer**
@@ -139,7 +135,7 @@ S3_SECRET_KEY=your-secret-key
S3_REGION=your-region
S3_BUCKET_NAME=your-bucket-name
# For S3-compatible storage (e.g., StorJ, MinIO)
# For S3-compatible storage (e.g., StorJ, RustFS)
# Leave empty for Amazon S3
S3_ENDPOINT_URL=https://your-s3-compatible-endpoint

View File

@@ -117,26 +117,37 @@ Please take a look at our [migration guide](/self-hosting/advanced/migration) fo
docker compose up -d
```
## Optional: Adding MinIO for File Storage
## Optional: Adding RustFS for File Storage
MinIO provides S3-compatible object storage for file uploads in Formbricks. If you want to enable features like image uploads, file uploads in surveys, or custom logos, you can add MinIO to your Docker setup.
RustFS provides S3-compatible object storage for file uploads in Formbricks. If you want to enable features
like image uploads, survey file uploads, or custom logos, you can run RustFS alongside Formbricks while
keeping the existing `S3_*` environment variables.
<Note>
For detailed information about file storage options and configuration, see our [File Uploads
For a broader overview of file storage options and required environment variables, see our [File Uploads
Configuration](/self-hosting/configuration/file-uploads) guide.
</Note>
<Warning>
**For production deployments with HTTPS**, use the [one-click setup script](/self-hosting/setup/one-click)
which automatically configures MinIO with Traefik, SSL certificates, and a subdomain (required for MinIO in
production). The setups below are suitable for local development or testing only.
which automatically configures RustFS with Traefik, SSL certificates, a dedicated `files.` subdomain, and
least-privilege service credentials. The examples below are best suited for development, testing, or custom
local setups.
</Warning>
<Warning>
The bundled RustFS examples on this page are convenience-oriented single-server setups. They work well for
development, evaluation, and smaller self-hosted deployments, but they are not the ideal RustFS architecture
for high-availability or larger-scale production storage. For stricter production requirements, use external
object storage or run a dedicated RustFS deployment separately.
</Warning>
### Quick Start: Using docker-compose.dev.yml
The fastest way to test MinIO with Formbricks is to use the included `docker-compose.dev.yml` which already has MinIO pre-configured.
The fastest way to test file uploads locally is to use the included `docker-compose.dev.yml`, which already
starts RustFS and auto-creates the `formbricks` bucket.
1. **Start MinIO and Services**
1. **Start the local stack**
From the repository root:
@@ -144,163 +155,155 @@ The fastest way to test MinIO with Formbricks is to use the included `docker-com
docker compose -f docker-compose.dev.yml up -d
```
This starts PostgreSQL, Valkey (Redis), MinIO, and Mailhog.
This starts PostgreSQL, Valkey (Redis), Mailhog, RustFS, a permissions helper, and a one-time bucket
bootstrap job.
2. **Access MinIO Console**
2. **Access the RustFS console**
Open http://localhost:9001 in your browser.
Open http://localhost:9001 in your browser and sign in with:
- Username: `devrustfs`
- Password: `devrustfs123`
Login credentials:
3. **Configure Formbricks**
- Username: `devminio`
- Password: `devminio123`
3. **Create Bucket**
- Click "Buckets" in the left sidebar
- Click "Create Bucket"
- Name it: `formbricks`
4. **Configure Formbricks**
Update your `.env` file or environment variables with MinIO configuration:
Update your `.env` file or environment variables:
```bash
# MinIO S3 Storage
S3_ACCESS_KEY="devminio"
S3_SECRET_KEY="devminio123"
S3_ACCESS_KEY="devrustfs"
S3_SECRET_KEY="devrustfs123"
S3_REGION="us-east-1"
S3_BUCKET_NAME="formbricks"
S3_ENDPOINT_URL="http://localhost:9000"
S3_FORCE_PATH_STYLE="1"
```
5. **Verify in MinIO Console**
4. **Verify uploads**
After uploading files in Formbricks, view them at http://localhost:9001:
- Navigate to Buckets → formbricks → Browse
- Your uploaded files will appear here
After uploading a file in Formbricks, open http://localhost:9001 and navigate to **Buckets → formbricks**
to confirm the object was stored successfully.
<Note>
The `docker-compose.dev.yml` file includes MinIO with console access on port 9001, making it easy to
visually verify file uploads. This is the recommended approach for development and testing.
The development compose file also runs a `rustfs-init` job so you do not need to create the bucket manually.
</Note>
### Manual MinIO Setup (Custom Configuration)
### Manual RustFS Setup (Custom Configuration)
<Note>
<strong>Recommended:</strong> If you can, use <code>docker-compose.dev.yml</code> for the fastest path. Use
this manual approach only when you need to integrate MinIO into an existing <code>docker-compose.yml</code>{" "}
or customize settings.
<strong>Recommended:</strong> Prefer <code>docker-compose.dev.yml</code> for local development unless you
need to fold RustFS into an existing custom Compose stack.
</Note>
If you prefer to add MinIO to your own `docker-compose.yml`, follow these steps:
If you want to add RustFS to your own `docker-compose.yml`, use a pinned RustFS image plus two helper
services:
1. **Add the MinIO service**
```yaml
services:
rustfs-perms:
image: busybox:1.36.1
user: "0:0"
command: ["sh", "-c", "mkdir -p /data && chown -R 10001:10001 /data"]
volumes:
- rustfs-data:/data
Add this service alongside your existing `formbricks` and `postgres` services:
rustfs:
image: rustfs/rustfs:1.0.0-alpha.93
restart: always
depends_on:
rustfs-perms:
condition: service_completed_successfully
command: /data
environment:
RUSTFS_ACCESS_KEY: "formbricks-root"
RUSTFS_SECRET_KEY: "change-this-secure-password"
RUSTFS_ADDRESS: ":9000"
RUSTFS_CONSOLE_ENABLE: "true"
RUSTFS_CONSOLE_ADDRESS: ":9001"
ports:
- "9000:9000"
- "9001:9001"
volumes:
- rustfs-data:/data
```yaml
services:
# ... your existing services (formbricks, postgres, redis/valkey, etc.)
rustfs-init:
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
depends_on:
- rustfs
environment:
RUSTFS_ADMIN_USER: "formbricks-root"
RUSTFS_ADMIN_PASSWORD: "change-this-secure-password"
RUSTFS_BUCKET_NAME: "formbricks"
entrypoint:
- /bin/sh
- -c
- |
until mc alias set rustfs http://rustfs:9000 "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD" >/dev/null 2>&1 \
&& mc ls rustfs >/dev/null 2>&1; do
sleep 2
done
mc mb rustfs/"$RUSTFS_BUCKET_NAME" --ignore-existing
```
minio:
image: minio/minio:latest
restart: always
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: "formbricks-root"
MINIO_ROOT_PASSWORD: "change-this-secure-password"
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web console
volumes:
- minio-data:/data
```
Declare the corresponding volume:
<Note>
For production pinning, consider using a digest (e.g., <code>minio/minio@sha256:...</code>) and review
periodically with <code>docker inspect minio/minio:latest</code>.
</Note>
```yaml
volumes:
rustfs-data:
driver: local
```
2. **Declare the MinIO volume**
Then configure Formbricks to use the RustFS credentials:
Add (or extend) your `volumes` block:
```bash
S3_ACCESS_KEY="formbricks-root"
S3_SECRET_KEY="change-this-secure-password"
S3_REGION="us-east-1"
S3_BUCKET_NAME="formbricks"
S3_ENDPOINT_URL="http://rustfs:9000"
S3_FORCE_PATH_STYLE="1"
```
```yaml
volumes:
postgres:
driver: local
redis:
driver: local
minio-data:
driver: local
```
3. **Start services**
```bash
docker compose up -d
```
4. **Open the MinIO Console & Create a Bucket**
- Visit **http://localhost:9001**
- Log in with:
- **Username:** `formbricks-root`
- **Password:** `change-this-secure-password`
- Go to **Buckets → Create Bucket**
- Name it: **`formbricks`**
5. **Configure Formbricks to use MinIO**
In your `.env` or `formbricks` service environment, set:
```bash
# MinIO S3 Storage
S3_ACCESS_KEY="formbricks-root"
S3_SECRET_KEY="change-this-secure-password"
S3_REGION="us-east-1"
S3_BUCKET_NAME="formbricks"
S3_ENDPOINT_URL="http://minio:9000"
S3_FORCE_PATH_STYLE="1"
```
<Note>
These credentials should match <code>MINIO_ROOT_USER</code> and <code>MINIO_ROOT_PASSWORD</code> above.
For local/dev this is fine. For production, create a dedicated MinIO user with restricted policies.
</Note>
6. **Verify uploads**
After uploading a file in Formbricks, check **http://localhost:9001**:
- **Buckets → formbricks → Browse**
You should see your uploaded files.
<Note>
Using the RustFS admin credentials directly is acceptable for local development and testing. For production,
prefer the [one-click setup script](/self-hosting/setup/one-click), which creates a separate least-privilege
service account automatically.
</Note>
#### Tips & Common Gotchas
- **Connection refused**: Ensure the `minio` container is running and port **9000** is reachable from the Formbricks container (use the internal URL `http://minio:9000`).
- **Bucket not found**: Create the `formbricks` bucket in the console before uploading.
- **Auth failed**: Confirm `S3_ACCESS_KEY`/`S3_SECRET_KEY` match MinIO credentials.
- **Permission denied on `/data`**: Ensure the mounted directory or volume is owned by UID `10001`. The
`rustfs-perms` helper handles this for Compose-managed volumes.
- **Connection refused**: Ensure the `rustfs` container is running and port `9000` is reachable from the
Formbricks container.
- **Bucket not found**: Confirm that `rustfs-init` completed successfully or create the bucket manually with
`mc`.
- **Auth failed**: Confirm that `S3_ACCESS_KEY` and `S3_SECRET_KEY` match the RustFS credentials configured on
the server.
- **Health check**: From the Formbricks container:
```bash
docker compose exec formbricks sh -c 'wget -O- http://minio:9000/minio/health/ready'
docker compose exec formbricks sh -c 'wget -O- http://rustfs:9000/health'
```
### Production Setup with Traefik
For production deployments, use the [one-click setup script](/self-hosting/setup/one-click) which automatically configures:
For production deployments, use the [one-click setup script](/self-hosting/setup/one-click), which
automatically configures:
- MinIO service with Traefik reverse proxy
- Dedicated subdomain (e.g., `files.yourdomain.com`) - **required for production**
- RustFS behind Traefik on a dedicated `files.yourdomain.com` subdomain
- Automatic SSL certificate generation via Let's Encrypt
- CORS configuration for your domain
- CORS configuration scoped to your Formbricks domain
- Rate limiting middleware
- Secure credential generation
- Separate RustFS admin and Formbricks service credentials
- A `rustfs-init` job that creates the bucket and access policy
The production setup from [formbricks.sh](https://github.com/formbricks/formbricks/blob/main/docker/formbricks.sh) includes advanced features not covered in this manual setup. For production use, we strongly recommend using the one-click installer.
The production setup from [formbricks.sh](https://github.com/formbricks/formbricks/blob/main/docker/formbricks.sh)
adds the reverse proxy wiring and bootstrap automation needed for long-lived deployments.
<Note>
Even in the one-click flow, bundled RustFS remains a convenience-oriented single-server deployment. For
higher availability, stricter operational requirements, or larger storage footprints, prefer external object
storage or a dedicated RustFS deployment managed separately from Formbricks.
</Note>
## Debug

View File

@@ -14,6 +14,14 @@ If youre looking to quickly set up a production instance of Formbricks on an
automatic SSL management via Lets Encrypt.
</Note>
<Warning>
The bundled RustFS option in this one-click setup is designed for convenience on a single server. It is a
good fit for small-scale or low-complexity self-hosted deployments, but it is not the ideal RustFS
architecture for high-availability or larger production environments. For stricter production requirements,
use external object storage or run a dedicated RustFS deployment separately from the Formbricks one-click
stack.
</Warning>
For other operating systems or a more customized installation, please refer to the advanced installation guide with [Docker](/self-hosting/setup/docker).
### Requirements
@@ -312,17 +320,22 @@ To restart Formbricks, simply run the following command:
The script will automatically restart all the Formbricks related containers and brings the entire stack up with the previous configuration.
## Cleanup MinIO init (optional)
## Cleanup RustFS init (optional)
During the one-click setup, a temporary `minio-init` service configures MinIO (bucket, policy, service user). It is idempotent and safe to leave in place; it will do nothing on subsequent starts once configuration exists.
During the one-click setup, a temporary `rustfs-init` service configures RustFS (bucket, policy, service
user). It is idempotent and safe to leave in place; it will do nothing on subsequent starts once the
configuration exists.
If you prefer to remove the `minio-init` service and its references after a successful setup, run:
If you prefer to remove the `rustfs-init` service and its references after a successful setup, run:
```
./formbricks.sh cleanup-minio-init
./formbricks.sh cleanup-rustfs-init
```
This only removes the init job and its Compose references; it does not delete any data or affect your MinIO configuration.
`./formbricks.sh cleanup-minio-init` is still available as a backward-compatible alias.
This only removes the init job and its Compose references; it does not delete any data or affect your RustFS
configuration.
## Uninstall

View File

@@ -1,5 +1,6 @@
// basic regex -- [whitespace](number)(rem)[whitespace or ;]
const REM_REGEX = /\b(\d+(\.\d+)?)(rem)\b/gi;
// Matches a CSS numeric value followed by "rem" — e.g. "1rem", "1.5rem", "16rem".
// Single character-class + single quantifier: no nested quantifiers, no backtracking risk.
const REM_REGEX = /([\d.]+)(rem)/gi; // NOSONAR -- single character-class quantifier on trusted CSS input; no backtracking risk
const PROCESSED = Symbol("processed");
const remtoEm = (opts = {}) => {
@@ -26,6 +27,36 @@ const remtoEm = (opts = {}) => {
};
};
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm()],
// Strips the `@layer properties { ... }` block that Tailwind v4 emits as a
// browser-compatibility fallback for `@property` declarations.
//
// Problem: CSS `@layer` at-rules are globally scoped by spec — they cannot be
// confined by a surrounding selector. Even though all other Formbricks survey
// styles are correctly scoped to `#fbjs`, the `@layer properties` block
// contains a bare `*, :before, :after, ::backdrop` selector that resets all
// `--tw-*` CSS custom properties on every element of the host page. This
// breaks shadows, rings, transforms, and other Tailwind utilities on any site
// that uses Tailwind v4 alongside the Formbricks SDK.
//
// The `@property` declarations already present in the same stylesheet cover
// the same browser-compatibility need for all supporting browsers, so removing
// `@layer properties` does not affect survey rendering.
//
// See: https://github.com/formbricks/js/issues/46
const stripLayerProperties = () => {
return {
postcssPlugin: "postcss-strip-layer-properties",
AtRule: {
layer: (atRule) => {
if (atRule.params === "properties") {
atRule.remove();
}
},
},
};
};
stripLayerProperties.postcss = true;
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm(), stripLayerProperties()],
};