Compare commits

...
8 Commits
Author SHA1 Message Date
M1ngdaXieandClaude Sonnet 5 bca5559eef fix: un-concatenate k3s/secret.yaml and .playwright-mcp/ gitignore entries
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 16:33:22 +08:00
M1ngdaXieandClaude Sonnet 5 921e8ae159 fix: set production VITE_API_URL/VITE_WS_URL explicitly in CI build
The Gitea Actions workflow checks out a fresh copy of the repo via
actions/checkout, which never has frontend/.env.local (it's gitignored,
only exists in the manually-maintained /root/realtime-collab clone).
Without it, Vite silently fell back to the localhost dev defaults and
baked a broken API URL into the production bundle — every CI-triggered
build overwrote a working manual build with a broken one.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 16:32:10 +08:00
M1ngdaXieandClaude Sonnet 5 8b1d2f14f1 fix: import backend image into k3s containerd before rollout
docker build only populates the Docker daemon's store; k3s runs its
own embedded containerd and never sees it, so imagePullPolicy: Never
kept redeploying whatever was last imported instead of new builds.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 16:09:36 +08:00
M1ngdaXieandClaude Sonnet 5 5def3c14e8 ci: add manual Gitea Actions deploy workflow
Rebuilds and rolls out the backend, then builds and syncs the
frontend to nginx. Triggered manually via workflow_dispatch so pushes
don't auto-deploy to production.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 15:26:33 +08:00
M1ngdaXie f811fa1e52 Merge branch 'master' into self-hosted 2026-08-15 14:56:22 +08:00
M1ngdaXieandClaude Sonnet 5 f363193fba chore: remove redundant target from tsconfig.node.json
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 14:56:10 +08:00
M1ngdaXieandClaude Sonnet 5 aa67446f7c fix: allow share-link users to view/edit and autosave via share token
Users without personal document access can still load/save state when
a valid share token is present, matching the permission level granted
by the share link.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-15 14:55:59 +08:00
M1ngdaXie afb04e5cd3 feat: migrate realtime-collab from Docker Compose to k3s
Add k3s manifests for postgres, redis, and backend

Fix users table constraint and init.sql
2026-03-25 01:19:00 +00:00
14 changed files with 510 additions and 11 deletions
+45
View File
@@ -0,0 +1,45 @@
name: Deploy
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest # hits the host runner (vps-runner) already on this box
env:
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build backend image
run: docker build -t realtime-collab-backend:latest ./backend
- name: Import image into k3s containerd
# docker build only populates the Docker daemon's store; k3s runs its
# own containerd (imagePullPolicy: Never, never pulls a registry), so
# the image must be explicitly imported or the pod keeps running
# whatever was last imported.
run: docker save realtime-collab-backend:latest | k3s ctr -n k8s.io images import -
- name: Roll out backend
run: |
kubectl rollout restart deployment/realtime-collab-backend
kubectl rollout status deployment/realtime-collab-backend --timeout=90s
- name: Build frontend
working-directory: frontend
env:
# frontend/.env.local holds these for the manually-maintained
# /root/realtime-collab checkout, but it's gitignored, so this
# actions/checkout-based workspace never has it — without setting
# them here, Vite silently falls back to the localhost dev
# defaults and bakes a broken API URL into the production bundle.
VITE_API_URL: https://collab.m1ngdaxie.com/api
VITE_WS_URL: wss://collab.m1ngdaxie.com/ws
run: |
npm ci
npm run build
- name: Deploy frontend
run: rsync -a --delete frontend/dist/ /var/www/realtime-collab/
+6 -1
View File
@@ -39,4 +39,9 @@ postgres_data/
#test folder profiles #test folder profiles
loadtest/pprof loadtest/pprof
/docs /docs
# K3s secrets
k3s/secret.yaml
.playwright-mcp/
+26 -1
View File
@@ -137,6 +137,15 @@ func (h *DocumentHandler) GetDocumentState(c *gin.Context) {
respondInternalError(c, "Failed to check permissions", err) respondInternalError(c, "Failed to check permissions", err)
return return
} }
if !canView && shareToken != "" {
// Logged-in user without personal permission: fall back to share link
valid, err := h.store.ValidateShareToken(c.Request.Context(), id, shareToken)
if err != nil {
respondInternalError(c, "Failed to validate share token", err)
return
}
canView = valid
}
if !canView { if !canView {
respondForbidden(c, "Access denied") respondForbidden(c, "Access denied")
return return
@@ -189,12 +198,28 @@ func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) {
return return
} }
// Check edit permission // Check edit permission (personal share OR edit-level share link)
shareToken := c.Query("share")
canEdit, err := h.store.CanEditDocument(c.Request.Context(), id, *userID) canEdit, err := h.store.CanEditDocument(c.Request.Context(), id, *userID)
if err != nil { if err != nil {
respondInternalError(c, "Failed to check permissions", err) respondInternalError(c, "Failed to check permissions", err)
return return
} }
if !canEdit && shareToken != "" {
valid, err := h.store.ValidateShareToken(c.Request.Context(), id, shareToken)
if err != nil {
respondInternalError(c, "Failed to validate share token", err)
return
}
if valid {
perm, err := h.store.GetShareLinkPermission(c.Request.Context(), id)
if err != nil {
respondInternalError(c, "Failed to get token permission", err)
return
}
canEdit = perm == "edit"
}
}
if !canEdit { if !canEdit {
respondForbidden(c, "Edit access denied") respondForbidden(c, "Edit access denied")
return return
+37 -2
View File
@@ -381,8 +381,6 @@ func (s *DocumentHandlerSuite) TestGetDocumentState_Success() {
s.assertSuccessResponse(w, http.StatusOK) s.assertSuccessResponse(w, http.StatusOK)
s.Equal("application/octet-stream", w.Header().Get("Content-Type")) s.Equal("application/octet-stream", w.Header().Get("Content-Type"))
// State should be empty bytes for new document
s.NotNil(w.Body.Bytes())
} }
func (s *DocumentHandlerSuite) TestGetDocumentState_EmptyState() { func (s *DocumentHandlerSuite) TestGetDocumentState_EmptyState() {
@@ -416,6 +414,17 @@ func (s *DocumentHandlerSuite) TestGetDocumentState_InvalidID() {
s.assertErrorResponse(w, http.StatusBadRequest, "bad_request", "Invalid document ID") s.assertErrorResponse(w, http.StatusBadRequest, "bad_request", "Invalid document ID")
} }
func (s *DocumentHandlerSuite) TestGetDocumentState_AuthenticatedWithShareToken() {
// Charlie (logged in, not owner/shared) reads Alice's public doc via share link
path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, s.testData.PublicShareToken)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.CharlieID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
s.Equal("application/octet-stream", w.Header().Get("Content-Type"))
}
// ======================================== // ========================================
// UpdateDocumentState Tests // UpdateDocumentState Tests
// ======================================== // ========================================
@@ -458,6 +467,32 @@ func (s *DocumentHandlerSuite) TestUpdateDocumentState_ViewOnlyDenied() {
s.router.ServeHTTP(w, httpReq) s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied") s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied")
} }
func (s *DocumentHandlerSuite) TestUpdateDocumentState_AuthenticatedWithEditShareToken() {
// Give Alice's public doc an "edit" share link
ctx := context.Background()
editToken, err := s.store.GenerateShareToken(ctx, s.testData.AlicePublicDoc, "edit")
s.Require().NoError(err)
// Charlie (logged in, not owner/shared) edits via edit share link
req := models.UpdateStateRequest{State: []byte("shared edit")}
path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, editToken)
w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.CharlieID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
}
func (s *DocumentHandlerSuite) TestUpdateDocumentState_AuthenticatedWithViewShareTokenDenied() {
// Alice's public doc has a "view" share link (from seed).
// Charlie (logged in) cannot write via a view-only share link.
req := models.UpdateStateRequest{State: []byte("attempt write")}
path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, s.testData.PublicShareToken)
w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.CharlieID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied")
}
func (s *DocumentHandlerSuite) TestUpdateDocumentState_Unauthorized() { func (s *DocumentHandlerSuite) TestUpdateDocumentState_Unauthorized() {
req := models.UpdateStateRequest{ req := models.UpdateStateRequest{
+4
View File
@@ -265,3 +265,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uniq_update_history_document_seq
CREATE INDEX IF NOT EXISTS idx_update_history_document_seq CREATE INDEX IF NOT EXISTS idx_update_history_document_seq
ON document_update_history(document_id, seq); ON document_update_history(document_id, seq);
-- Add 'guest' as a valid provider for guest mode login
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_provider_check;
ALTER TABLE users ADD CONSTRAINT users_provider_check CHECK (provider IN ('google', 'github', 'guest'));
+6 -2
View File
@@ -64,12 +64,16 @@ export const documentsApi = {
}, },
// Update document Yjs state // Update document Yjs state
updateState: async (id: string, state: Uint8Array): Promise<void> => { updateState: async (id: string, state: Uint8Array, shareToken?: string): Promise<void> => {
// Create a new ArrayBuffer copy to ensure compatibility // Create a new ArrayBuffer copy to ensure compatibility
const buffer = new ArrayBuffer(state.byteLength); const buffer = new ArrayBuffer(state.byteLength);
new Uint8Array(buffer).set(state); new Uint8Array(buffer).set(state);
const response = await authFetch(`${API_BASE_URL}/documents/${id}/state`, { const url = shareToken
? `${API_BASE_URL}/documents/${id}/state?share=${shareToken}`
: `${API_BASE_URL}/documents/${id}/state`;
const response = await authFetch(url, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/octet-stream" }, headers: { "Content-Type": "application/octet-stream" },
body: buffer, body: buffer,
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { documentsApi } from '../api/document'; import { documentsApi } from '../api/document';
export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => { export const useAutoSave = (documentId: string, ydoc: Y.Doc | null, shareToken?: string) => {
const saveTimeoutRef = useRef<number | null>(null); const saveTimeoutRef = useRef<number | null>(null);
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
@@ -25,7 +25,7 @@ export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => {
isSavingRef.current = true; isSavingRef.current = true;
try { try {
const state = Y.encodeStateAsUpdate(ydoc); const state = Y.encodeStateAsUpdate(ydoc);
await documentsApi.updateState(documentId, state); await documentsApi.updateState(documentId, state, shareToken);
console.log('✓ Document saved to database'); console.log('✓ Document saved to database');
} catch (error) { } catch (error) {
console.error('Failed to save document:', error); console.error('Failed to save document:', error);
@@ -44,5 +44,5 @@ export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
}; };
}, [documentId, ydoc]); }, [documentId, ydoc, shareToken]);
}; };
+1 -1
View File
@@ -17,7 +17,7 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
const [role, setRole] = useState<string | null>(null); const [role, setRole] = useState<string | null>(null);
// Enable auto-save when providers are ready // Enable auto-save when providers are ready
useAutoSave(documentId, providers?.ydoc || null); useAutoSave(documentId, providers?.ydoc || null, shareToken);
// Fetch permission when component mounts // Fetch permission when component mounts
useEffect(() => { useEffect(() => {
-1
View File
@@ -1,7 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"], "lib": ["ES2023"],
"module": "ESNext", "module": "ESNext",
"types": ["node"], "types": ["node"],
+49
View File
@@ -0,0 +1,49 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: realtime-collab-backend
spec:
replicas: 1
selector:
matchLabels:
app: realtime-collab-backend
template:
metadata:
labels:
app: realtime-collab-backend
spec:
containers:
- name: backend
image: realtime-collab-backend:latest
imagePullPolicy: Never
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: realtime-collab-secret
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "300m"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: realtime-collab-backend-svc
spec:
type: NodePort
selector:
app: realtime-collab-backend
ports:
- port: 8080
targetPort: 8080
nodePort: 30080
+178
View File
@@ -0,0 +1,178 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-init-sql
data:
init.sql: |
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('editor', 'kanban')),
yjs_state BYTEA,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_documents_type ON documents(type);
CREATE INDEX idx_documents_created_at ON documents(created_at DESC);
CREATE TABLE IF NOT EXISTS document_updates (
id SERIAL PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
update BYTEA NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_updates_document_id ON document_updates(document_id);
CREATE INDEX idx_updates_created_at ON document_updates(created_at DESC);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
avatar_url TEXT,
provider VARCHAR(50) NOT NULL CHECK (provider IN ('google', 'github', 'guest')),
provider_user_id VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_login_at TIMESTAMPTZ,
UNIQUE(provider, provider_user_id)
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_provider ON users(provider, provider_user_id);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
user_agent TEXT,
ip_address VARCHAR(45),
UNIQUE(token_hash)
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_documents_owner_id ON documents(owner_id);
CREATE TABLE IF NOT EXISTS document_shares (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission VARCHAR(20) NOT NULL CHECK (permission IN ('view', 'edit')),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
UNIQUE(document_id, user_id)
);
CREATE INDEX idx_shares_document_id ON document_shares(document_id);
CREATE INDEX idx_shares_user_id ON document_shares(user_id);
CREATE INDEX idx_shares_permission ON document_shares(document_id, permission);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS share_token VARCHAR(255);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT false NOT NULL;
CREATE INDEX IF NOT EXISTS idx_documents_share_token ON documents(share_token) WHERE share_token IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_documents_is_public ON documents(is_public) WHERE is_public = true;
ALTER TABLE documents ADD CONSTRAINT check_public_has_token
CHECK (is_public = false OR (is_public = true AND share_token IS NOT NULL));
ALTER TABLE documents ADD COLUMN IF NOT EXISTS share_permission VARCHAR(20) DEFAULT 'edit' CHECK (share_permission IN ('view', 'edit'));
CREATE INDEX IF NOT EXISTS idx_documents_share_permission ON documents(share_permission) WHERE is_public = true;
CREATE TABLE IF NOT EXISTS oauth_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_type VARCHAR(50) DEFAULT 'Bearer',
expires_at TIMESTAMPTZ NOT NULL,
scope TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT oauth_tokens_user_id_provider_key UNIQUE (user_id, provider)
);
CREATE INDEX idx_oauth_tokens_user_id ON oauth_tokens(user_id);
CREATE TABLE IF NOT EXISTS document_versions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
yjs_snapshot BYTEA NOT NULL,
text_preview TEXT,
version_number INTEGER NOT NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
version_label TEXT,
is_auto_generated BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_document_version UNIQUE(document_id, version_number)
);
CREATE INDEX idx_document_versions_document_id ON document_versions(document_id, created_at DESC);
CREATE INDEX idx_document_versions_created_by ON document_versions(created_by);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS version_count INTEGER DEFAULT 0;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS last_snapshot_at TIMESTAMPTZ;
CREATE OR REPLACE FUNCTION get_next_version_number(p_document_id UUID)
RETURNS INTEGER AS $$
DECLARE
next_version INTEGER;
BEGIN
SELECT COALESCE(MAX(version_number), 0) + 1
INTO next_version
FROM document_versions
WHERE document_id = p_document_id;
RETURN next_version;
END;
$$ LANGUAGE plpgsql;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE document_updates ENABLE ROW LEVEL SECURITY;
ALTER TABLE document_shares ENABLE ROW LEVEL SECURITY;
ALTER TABLE document_versions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow all operations on users" ON users FOR ALL USING (true);
CREATE POLICY "Allow all operations on sessions" ON sessions FOR ALL USING (true);
CREATE POLICY "Allow all operations on oauth_tokens" ON oauth_tokens FOR ALL USING (true);
CREATE POLICY "Allow all operations on documents" ON documents FOR ALL USING (true);
CREATE POLICY "Allow all operations on document_updates" ON document_updates FOR ALL USING (true);
CREATE POLICY "Allow all operations on document_shares" ON document_shares FOR ALL USING (true);
CREATE POLICY "Allow all operations on document_versions" ON document_versions FOR ALL USING (true);
CREATE TABLE IF NOT EXISTS stream_checkpoints (
document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
last_stream_id TEXT NOT NULL,
last_seq BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_stream_checkpoints_updated_at ON stream_checkpoints(updated_at DESC);
CREATE TABLE IF NOT EXISTS document_update_history (
id BIGSERIAL PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
stream_id TEXT NOT NULL,
seq BIGINT NOT NULL,
payload BYTEA NOT NULL,
msg_type TEXT,
server_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_update_history_document_stream_id ON document_update_history(document_id, stream_id);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_update_history_document_seq ON document_update_history(document_id, seq);
CREATE INDEX IF NOT EXISTS idx_update_history_document_seq ON document_update_history(document_id, seq);
+69
View File
@@ -0,0 +1,69 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
args: ["-c", "shared_buffers=128MB", "-c", "max_connections=50"]
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: realtime-collab-secret
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
- name: init-sql
mountPath: /docker-entrypoint-initdb.d
readinessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-pvc
- name: init-sql
configMap:
name: postgres-init-sql
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
+61
View File
@@ -0,0 +1,61 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
args: ["redis-server", "--appendonly", "yes", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
ports:
- containerPort: 6379
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "200m"
volumeMounts:
- name: redis-data
mountPath: /data
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-pvc
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
+25
View File
@@ -0,0 +1,25 @@
apiVersion: v1
kind: Secret
metadata:
name: realtime-collab-secret
type: Opaque
stringData:
# Postgres
POSTGRES_USER: "replace"
POSTGRES_PASSWORD: "replace"
POSTGRES_DB: "replace"
# Backend
DATABASE_URL: "postgres://user:pass@postgres:5432/dbname?sslmode=disable"
REDIS_URL: "redis://redis:6379"
JWT_SECRET: "replace"
PORT: "8080"
ENVIRONMENT: "production"
BACKEND_URL: "https://collab.m1ngdaxie.com"
FRONTEND_URL: "https://collab.m1ngdaxie.com"
ALLOWED_ORIGINS: "https://collab.m1ngdaxie.com"
GOOGLE_CLIENT_ID: "replace"
GOOGLE_CLIENT_SECRET: "replace"
GOOGLE_REDIRECT_URL: "https://collab.m1ngdaxie.com/api/auth/google/callback"
GITHUB_CLIENT_ID: "replace"
GITHUB_CLIENT_SECRET: "replace"
GITHUB_REDIRECT_URL: "https://collab.m1ngdaxie.com/api/auth/github/callback"