- Bottom tab bar on mobile instead of side icon strip - Conversation list and chat view are mutually exclusive on mobile - Back button in chat header returns to conversation list on mobile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CloudLink
End-to-end encrypted messaging and cloud drive. The server stores only ciphertext — it cannot read messages or files under any circumstance.
Features
- Zero-knowledge messaging — X25519 ECDH key exchange; AES-256-GCM encrypted messages
- Encrypted drive — Files are encrypted in-browser before upload; server stores opaque blobs
- Real-time delivery — WebSocket relay; server forwards ciphertext without decrypting
- Two-factor authentication — TOTP (Google Authenticator, Authy, etc.) with backup codes
- Progressive Web App — installable from the browser on any OS
- Desktop app — native Electron wrapper for Mac, Windows, and Linux
- Admin portal — manage users, storage quotas, and admin flags
- CI/CD — Gitea Actions builds Docker images and desktop installers on every push
Architecture
Browser / Desktop app
│ HTTPS + WSS
▼
nginx (TLS termination, reverse proxy)
│
┌────┴──────┐
│ frontend │ React 18 SPA served by nginx:alpine
└───────────┘
│ /api/* and /ws proxied
▼
┌────────────┐
│ backend │ Express + TypeScript, port 3001
└─────┬──────┘
│
┌─────▼──────┐
│ PostgreSQL │ pg:16-alpine, persistent volume
└────────────┘
All encryption and decryption happens exclusively in the browser/client. The backend is a dumb relay and storage layer.
Encryption Model
Keys
| Key | Algorithm | Where it lives |
|---|---|---|
| Password-derived key | PBKDF2-SHA256 (600 000 iterations) | In-memory only, derived at login |
| Master key | AES-256-GCM | Encrypted with derived key, stored on server |
| ECDH keypair | P-256 ECDH | Private key encrypted with master key, stored on server; public key stored plaintext |
| Conversation key | AES-256-GCM | Per-conversation symmetric key, sealed with each member's P-256 public key |
| File key | AES-256-GCM | Per-file symmetric key, encrypted with owner's master key |
Message flow
- Alice opens a conversation with Bob
- Client generates a random AES-256-GCM conversation key
- Key is sealed with Bob's P-256 public key (ephemeral ECDH + HKDF-SHA256 + AES-GCM)
- Ciphertext of the key is stored in
conversation_members.encrypted_key - Each message is encrypted with the conversation key
- Server stores and relays only ciphertext — it never sees plaintext
File flow
- Client generates a random AES-256-GCM file key
- File is encrypted in-browser with the file key
- File key is encrypted with the user's master key
- Encrypted file and encrypted file key are uploaded; filename is also encrypted
- Server stores opaque bytes at a UUID-based path
Tech Stack
Backend
- Node.js 20 + TypeScript
- Express 4, Helmet, express-rate-limit
wsWebSocket server- PostgreSQL 16 with pgcrypto
- Argon2id for password hashing
jsonwebtoken— 15-min access tokens + 30-day refresh tokensotplibfor TOTP 2FAmulterfor file uploadszodfor request validation
Frontend
- React 18 + Vite + TypeScript
- Tailwind CSS
- Zustand (auth + crypto keys in-memory store)
- TanStack Query v5
- React Router v6
vite-plugin-pwa(PWA / installable)- Web Crypto API (no external crypto libraries)
lucide-reacticonsdate-fns
Desktop
- Electron 33
- electron-store (persistent server URL)
- electron-builder (Mac DMG, Windows NSIS, Linux AppImage)
Infrastructure
- Docker + Docker Compose
- nginx (TLS, reverse proxy, HSTS, security headers)
- Gitea Actions (CI/CD)
Running Locally (Development)
Prerequisites
- Node.js 20+
- Docker + Docker Compose
1. Clone and configure
git clone https://git.necloud.us/nate/cloudlink.git
cd cloudlink
cp .env.example .env # edit values
.env minimum:
POSTGRES_DB=cloudlink
POSTGRES_USER=cloudlink
POSTGRES_PASSWORD=changeme
JWT_SECRET=change-me-at-least-32-chars
JWT_REFRESH_SECRET=change-me-at-least-32-chars
MAX_FILE_SIZE_MB=100
2. Start the database
docker compose up -d postgres
3. Run the backend
cd backend
npm install
npm run dev # http://localhost:3001
4. Run the frontend
cd frontend
npm install
npm run dev # http://localhost:5173
Set VITE_API_URL=http://localhost:3001/api and VITE_WS_URL=ws://localhost:3001/ws in frontend/.env.local.
Production Deployment (Docker)
Prerequisites
- Docker + Docker Compose on the server
- SSL certificate at
nginx/ssl/fullchain.pemandnginx/ssl/privkey.pem
Option A — Build locally, run on server
docker compose build
docker compose up -d
Option B — Pull pre-built images from CI (recommended)
After a successful Gitea Actions run:
echo "$REGISTRY_TOKEN" | docker login git.necloud.us -u nate --password-stdin
docker compose pull backend frontend
docker compose up -d --no-build
Environment variables (production .env)
POSTGRES_DB=cloudlink
POSTGRES_USER=cloudlink
POSTGRES_PASSWORD=<strong-password>
JWT_SECRET=<64-char-random-string>
JWT_REFRESH_SECRET=<64-char-random-string>
HOST_PORT=443
MAX_FILE_SIZE_MB=100
Generate strong secrets:
openssl rand -hex 32
First admin user
There are no default credentials. Register a normal account via the UI, then promote it to admin with one SQL command:
docker exec -i cloudlink-postgres-1 psql -U cloudlink -d cloudlink \
-c "UPDATE users SET is_admin = true WHERE email = 'you@example.com';"
After that, the Admin link appears in the sidebar. From there you can promote or demote any other user without touching SQL again.
Tip: The container name may vary. Run
docker ps --format '{{.Names}}'to confirm it.
Desktop App
Install pre-built (from Gitea release)
Download the installer for your OS from Releases:
| OS | File | Install |
|---|---|---|
| Mac (Apple Silicon) | CloudLink-*-arm64.dmg |
Open DMG, drag to Applications, right-click → Open |
| Mac (Intel) | CloudLink-*.dmg |
Open DMG, drag to Applications, right-click → Open |
| Windows | CloudLink Setup *.exe |
Run installer, follow wizard |
| Linux | CloudLink-*.AppImage |
chmod +x CloudLink-*.AppImage && ./CloudLink-*.AppImage |
On first launch the app prompts for your CloudLink server URL (e.g. https://cloudlink.example.com). This is saved and used for all future launches.
Build locally
cd desktop
npm install
npm run build:mac # requires macOS
npm run build:win # requires macOS or Linux (Wine)
npm run build:linux # any platform
Outputs go to desktop/dist/.
Admin Portal
Accessible at /admin for users with is_admin = true.
- View all users, registration date, last seen
- Per-user storage usage vs quota (colour-coded bar)
- Inline quota editing (MB)
- Toggle admin flag per user
- Aggregate stats (total users, total storage, total files)
CI/CD (Gitea Actions)
Workflow: .gitea/workflows/docker-build.yml
| Job | Trigger | Action |
|---|---|---|
build-backend |
every push / PR | Build + push backend:latest and backend:<sha> to Gitea registry |
build-frontend |
every push / PR | Build + push frontend:latest and frontend:<sha> |
build-desktop |
every push / PR | Build Linux AppImage + Windows NSIS; upload as workflow artifacts (30-day retention) |
deploy |
push to master |
SSH into server, pull new images, restart containers |
release |
push of a v* tag |
Create Gitea release, attach desktop installers |
Required secrets (Repo → Settings → Secrets)
| Secret | Value |
|---|---|
REGISTRY_USER |
Gitea username (e.g. nate) |
REGISTRY_TOKEN |
Gitea API token with package:write scope |
DEPLOY_HOST |
Server hostname or IP |
DEPLOY_USER |
SSH user |
DEPLOY_SSH_KEY |
SSH private key (contents of ~/.ssh/id_ed25519) |
DEPLOY_PATH |
Absolute path to cloudlink on server (e.g. /opt/cloudlink) |
Tagging a release
git tag v1.2.0 && git push origin v1.2.0
This triggers the release job which creates a Gitea release entry and attaches the built installers.
Runner setup
A Gitea act runner is required. Install on your server:
wget https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64 -O act_runner
chmod +x act_runner
# Get registration token from:
# Gitea → Site Administration → Actions → Runners → Create Runner
./act_runner register \
--no-interactive \
--instance https://git.necloud.us \
--token <TOKEN> \
--name cloudlink-runner \
--labels ubuntu-latest:docker://node:20-bullseye
./act_runner daemon
The runner host must have Docker installed.
Security Notes
- Crypto keys are never written to disk or localStorage — they live in Zustand's in-memory store and are lost on page reload (by design). Users re-derive them from their password at each login.
- The server stores only:
password_hash(Argon2id),kdf_salt,encrypted_master_key,encrypted_private_key, and ciphertext blobs. No plaintext content. - JWTs are short-lived (15 min). Refresh tokens are rotated on use and hashed before storage.
- nginx enforces HSTS, X-Frame-Options, X-Content-Type-Options, and a strict CSP.
- File paths on disk are UUID-based — not guessable from filenames.
- Rate limiting is applied to auth endpoints.
Database Schema
See backend/src/db/schema.sql. Tables:
| Table | Purpose |
|---|---|
users |
Account credentials, KDF salt, encrypted master key, 2FA state, quota |
user_keys |
X25519 public key + encrypted private key |
refresh_tokens |
Hashed refresh tokens with expiry |
conversations |
Direct message threads |
conversation_members |
Per-member encrypted conversation key |
messages |
Ciphertext-only message records |
files |
Encrypted file metadata + storage path |
Repository
Source: https://git.necloud.us/nate/cloudlink
Container registry: git.necloud.us/nate/cloudlink/{backend,frontend}:latest