A scalable, End-to-End encrypted messenger built on NestJS microservices. Developed as a bachelor diploma project, released as open-source.
┌──────────────────────────────────────────────┐
│ CLIENT (Web / Mobile) │
└───────────────────┬──────────────────────────┘
│ HTTPS / WSS
┌───────────────────▼──────────────────────────┐
│ Traefik Ingress (TLS termination) │
└───────┬───────────────────────────┬──────────┘
│ │
┌───────▼──────────┐ ┌────────────▼──────────┐
│ API Gateway │ │ Web Frontend │
│ HTTP + Socket.IO│ │ (Vite + React + E2EE)│
└──┬────────────┬──┘ └───────────────────────┘
│ gRPC │ gRPC
┌──▼──────┐ ┌──▼────────────────────┐
│ Auth │ │ Communication Service│
│ Service │ │ (chats, messages, │
│ │ │ sender keys) │
└──┬──────┘ └──┬────────────────────┘
│ │
┌──▼─────────────▼──────────────────────────────┐
│ PostgreSQL │
├───────────────────────────────────────────────┤
│ auth DB: users, sessions, service_identities │
│ comm DB: chats, chat_members, messages, │
│ sender_keys, message_status, │
│ message_reactions │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ Redis │
│ WebSocket pub/sub · session cache │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ Observability (gomin-monitoring) │
│ OTel Collector → Prometheus / Loki / Tempo │
│ Grafana │
└───────────────────────────────────────────────┘
| Service | Port | Responsibility |
|---|---|---|
api-gateway |
3000 (HTTP/WS) | HTTP routing, WebSocket, session auth |
auth |
5000 (gRPC) | Registration, login, JWT, sessions, E2EE key storage |
communication-service |
5001 (gRPC) | Chats (DIRECT/GROUP/CHANNEL), messages, reactions, sender keys |
| Technology | Purpose |
|---|---|
| Node.js 22 + NestJS | All services |
| TypeScript | Type safety across monorepo |
gRPC (@nestjs/microservices) |
Inter-service communication |
| Knex | Query builder + migrations |
| PostgreSQL | Primary database |
| Redis | WebSocket pub/sub, session cache |
| Socket.IO | Real-time WebSocket gateway |
| Technology | Purpose |
|---|---|
| Kubernetes (k3s) | Container orchestration |
| Helm | Kubernetes package manager |
| Traefik | Ingress controller (built into k3s) |
| cert-manager | Automatic TLS via Let's Encrypt |
| MinIO | S3-compatible object storage |
| Technology | Purpose |
|---|---|
| OpenTelemetry Collector | Telemetry aggregation (traces, logs, metrics) |
| Prometheus | Metrics storage |
| Loki | Log storage |
| Tempo | Distributed tracing |
| Grafana | Dashboards (http://grafana.84.247.133.45.nip.io) |
| Technology | Purpose |
|---|---|
| Vite + React | Web client |
| Web Crypto API | Client-side E2EE |
Security is layered across transport, authentication, authorization, and message encryption.
All external traffic is encrypted via HTTPS/WSS. TLS certificates are issued automatically by Let's Encrypt and managed by cert-manager:
cert-managerwatches Ingress annotations (cert-manager.io/cluster-issuer: letsencrypt-prod)- Performs ACME HTTP-01 challenge — creates a temporary Pod + Ingress to prove domain ownership
- Stores the issued certificate as a Kubernetes Secret (
api-gateway-tls,web-tls) - Traefik reads those Secrets and terminates TLS at the edge
Internal service-to-service traffic travels over the cluster network (no public exposure). gRPC between services is unencrypted within the cluster but isolated by Kubernetes network policies and namespaces.
Registration:
- Password is hashed with Argon2id — never stored in plaintext
- Client generates an RSA-2048 key pair locally
- Private key is wrapped with AES-256-GCM using a key derived from the password via PBKDF2 + unique salt
- Server stores:
publicKey,encryptedPrivateKey,encryptionIv,encryptionAuthTag,encryptionSalt - Server never sees the plaintext private key
Login:
- Server verifies password against the Argon2 hash
- Returns a session token (opaque, stored in DB + Redis) and the encrypted E2EE bundle
- Client decrypts the private key locally using the password
Sessions:
- Session token is sent as
Authorization: Bearer <token>on every HTTP request SessionGuardinapi-gatewayvalidates the token via gRPC call toauth- WebSocket connections authenticate via
?token=<sessionToken>at handshake time (WsSessionGuard)
Every gRPC call between services is authenticated by MicroserviceIdentityGuard:
api-gateway → auth service:
1. api-gateway holds a SERVICE_SECRET configured via Kubernetes Secret
2. Calls AuthenticateServiceIdentity(serviceName, serviceSecret) → gets a JWT (RS256)
3. Attaches the JWT as metadata on every subsequent gRPC call
4. auth validates the JWT on each call via MicroserviceIdentityGuard + RequirePermission
Each service has a fixed set of permissions (e.g. SESSIONS_READ, USERS_WRITE) defined in the database. The guard checks both token validity and permission presence. A compromised service can only call endpoints it is explicitly permitted to.
HTTP and WebSocket CORS are both restricted to the frontend origin via the CORS_ORIGIN environment variable (stored in the api-gateway-secret Kubernetes Secret):
CORS_ORIGIN=https://app.84.247.133.45.nip.io
app.enableCors() and Socket.IO's createIOServer both read this value. Requests from any other origin are rejected by the browser before they reach the server.
All HTTP request bodies are validated globally by NestJS ValidationPipe:
new ValidationPipe({
whitelist: true, // strips fields not in the DTO
forbidNonWhitelisted: true,// rejects requests with unknown fields
transform: true, // coerces types automatically
})This prevents mass-assignment attacks and ensures only declared fields reach the service layer.
Messages are encrypted on the client and the server stores only ciphertext. Two schemes are used depending on group size:
Direct / small group — per-message hybrid encryption:
1. Client generates a random AES-256 key for the message
2. Encrypts the message body with AES-GCM → encryptedContent + iv + authTag
3. Wraps the AES key with each recipient's RSA-2048 public key (RSA-OAEP)
4. Uploads: one ciphertext blob + N wrapped keys (one per recipient)
Large groups — Sender Keys:
1. Each sender generates their own sender key (random chain material)
2. Distributes it to each group member via RSA-OAEP (pairwise, one-time)
3. For each message: derives a message key from the KDF chain, encrypts with AES-GCM
4. Uploads one ciphertext blob; all recipients derive the same key locally
→ O(1) ciphertext per message instead of O(N) RSA operations
Key rotation on member removal: remaining members generate new sender key material and redistribute it, making future messages unreadable to the removed user.
All sensitive values (passwords, tokens, DB URLs) are stored in Kubernetes Secrets — never in git:
api-gateway-secret → CORS_ORIGIN, SERVICE_SECRET, REDIS_PASSWORD, ...
auth-secret → JWT_SIGNING_KEYS, DATABASE_URL, ...
communication-service-secret → DATABASE_URL, SERVICE_SECRET, ...
redis-secret → REDIS_PASSWORD
minio-secret → rootUser, rootPassword
Pods mount secrets via envFrom.secretRef — the application reads them as environment variables without any knowledge of the secret layer.
The GitHub Actions ServiceAccount (github-actions) has write access only to gomin-apps and gomin-infra. It cannot read or modify kube-system, create namespaces, or access any cluster-wide resource. See the CI/CD ServiceAccount section.
All routes are prefixed with /api. Authenticated routes require Authorization: Bearer <sessionToken>.
| Method | Path | Description |
|---|---|---|
POST |
/register |
Register a new user with E2EE key bundle |
POST |
/login |
Authenticate; returns session token + encrypted private key bundle |
POST |
/logout |
Invalidate the current session |
GET |
/sessions |
List all active sessions for the current user |
DELETE |
/sessions |
Terminate all sessions except the current one |
DELETE |
/sessions/:targetSessionToken |
Terminate a specific session |
POST |
/change-password |
Change password and rotate E2EE keys |
GET |
/users/:userId/public-key |
Fetch another user's RSA public key for key exchange |
| Method | Path | Description |
|---|---|---|
POST |
/ |
Create a new chat (DIRECT, GROUP, or CHANNEL) |
GET |
/ |
List all chats for the current user (cursor-paginated) |
GET |
/:chatId |
Get chat details and member list |
POST |
/:chatId/members |
Add a member (with optional history cutoff) |
DELETE |
/:chatId/members/:targetUserId |
Remove a member |
PATCH |
/:chatId/members/:targetUserId/role |
Update a member's role (ADMIN or MEMBER) |
POST |
/:chatId/transfer-ownership |
Transfer chat ownership to another member |
DELETE |
/:chatId |
Delete the chat and all associated data |
| Method | Path | Description |
|---|---|---|
POST |
/ |
Send an encrypted message |
GET |
/ |
Fetch messages (cursor-paginated by beforeMessageId) |
PATCH |
/:messageId |
Edit an encrypted message |
DELETE |
/:messageId |
Soft-delete a message |
POST |
/:messageId/reactions |
Add an emoji reaction |
DELETE |
/:messageId/reactions/:emoji |
Remove an emoji reaction |
POST |
/read |
Mark messages as read up to upToMessageId |
| Method | Path | Description |
|---|---|---|
POST |
/ |
Store (or rotate) sender chain keys for group members |
GET |
/ |
Get all sender keys stored for the current user in this chat |
GET |
/:senderId |
Get a specific sender's chain key for the current user |
The API Gateway exposes a Socket.IO endpoint for real-time messaging. The session token is passed at handshake time via auth.token or the token query parameter.
| Channel | Pattern | Description |
|---|---|---|
| User channel | user:<userId> |
Personal events: presence updates, sender-key deliveries |
| Chat channel | chat:<chatId> |
Shared chat events: messages, reactions, typing indicators |
Clients are auto-subscribed to their user channel on connection. Chat channels require an explicit chat:subscribe event.
| Event | Payload | Description |
|---|---|---|
chat:subscribe |
{ chatId } |
Subscribe to real-time updates for a chat |
chat:subscribe_many |
{ chatIds[] } |
Subscribe to multiple chats at once |
chat:unsubscribe |
{ chatId } |
Unsubscribe from a chat channel |
chat:unsubscribe_many |
{ chatIds[] } |
Unsubscribe from multiple chats |
typing:start |
{ chatId } |
Broadcast "user is typing" to chat members |
typing:stop |
{ chatId } |
Broadcast "user stopped typing" |
presence:ping |
(none) | Keep-alive; re-broadcasts online status |
sender_key:distribute |
{ chatId, recipientId, encryptedSenderKey, keyVersion } |
Deliver an RSA-encrypted sender key to a specific recipient |
| Event | Channel | Description |
|---|---|---|
message:new |
chat | New encrypted message |
message:updated |
chat | Message content edited |
message:deleted |
chat | Message soft-deleted |
message:read_receipt |
chat | Messages marked as read |
message:reaction_added |
chat | Emoji reaction added |
message:reaction_removed |
chat | Emoji reaction removed |
typing:start |
chat | A member is typing (chatId, userId, username) |
typing:stop |
chat | A member stopped typing |
presence:update |
user | User came online or went offline (userId, online: bool) |
sender_key:received |
user | Encrypted sender key delivered (chatId, senderId, encryptedSenderKey, keyVersion) |
- Node.js 22+
- pnpm 9+
- Docker (for local infrastructure)
- PostgreSQL 15+, Redis 7+
git clone https://github.com/Dmutre/Gomin.git
cd Gomin
pnpm install# Start infrastructure (PostgreSQL + Redis)
docker compose up -d
# Run a service
pnpm nx serve api-gateway
pnpm nx serve auth
pnpm nx serve communication-service
# Build all
pnpm nx run-many --target=build --all
# Lint
pnpm nx run-many --target=lint --all
# Dependency graph
pnpm nx graph| Variable | Service | Description |
|---|---|---|
DATABASE_URL |
auth, communication-service | PostgreSQL connection string |
REDIS_HOST / REDIS_PORT / REDIS_PASSWORD |
all | Redis connection |
JWT_SIGNING_KEYS |
auth | JSON array of RSA key pairs |
GRPC_PORT |
auth, communication-service | gRPC listen port |
AUTH_SERVICE_URL |
api-gateway, communication-service | gRPC address of auth |
COMMUNICATION_SERVICE_URL |
api-gateway | gRPC address of communication-service |
SERVICE_NAME / SERVICE_SECRET |
api-gateway, communication-service | Inter-service auth identity |
CORS_ORIGIN |
api-gateway | Allowed frontend origin (e.g. https://app.example.com) |
OTEL_EXPORTER_OTLP_ENDPOINT |
all | OTel Collector gRPC endpoint |
charts/
platform/ # Cluster-level: namespaces
infra/ # Redis + MinIO
api-gateway/ # HTTP / WebSocket gateway
auth/ # Auth gRPC microservice + migrations
communication-service/ # Messaging gRPC microservice + migrations
web/ # Static frontend
monitoring/ # Prometheus, Loki, Tempo, Grafana, OTel Collector
Namespaces:
| Namespace | Contents |
|---|---|
gomin-infra |
Redis, MinIO |
gomin-apps |
api-gateway, auth, communication-service, web |
gomin-monitoring |
Prometheus, Grafana, Loki, Tempo, OTel Collector |
Live endpoints:
| Service | URL |
|---|---|
| API Gateway | https://api.84.247.133.45.nip.io |
| Web Frontend | https://app.84.247.133.45.nip.io |
| Grafana | http://grafana.84.247.133.45.nip.io |
| Swagger | https://api.84.247.133.45.nip.io/docs |
1. Bootstrap namespaces + CI/CD ServiceAccount:
kubectl apply -f k8s/bootstrap/ci-rbac.yamlCreates: gomin-apps, gomin-infra, ServiceAccount github-actions, RBAC, long-lived token.
2. Install metrics-server (required for HPA):
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml3. Create all secrets interactively (passwords are never written to disk or git):
chmod +x scripts/setup-secrets.sh
./scripts/setup-secrets.shPrompts for: Redis password, MinIO credentials, PostgreSQL URLs, JWT keys, service secrets, CORS origin. Re-running updates existing secrets safely (idempotent).
4. Deploy namespaces + infrastructure:
helm upgrade --install platform ./charts/platform
helm upgrade --install infra ./charts/infra --namespace gomin-infra5. Deploy application services:
helm upgrade --install auth ./charts/auth --namespace gomin-apps
helm upgrade --install communication-service ./charts/communication-service --namespace gomin-apps
helm upgrade --install api-gateway ./charts/api-gateway --namespace gomin-apps
helm upgrade --install web ./charts/web --namespace gomin-apps6. (Optional) Deploy monitoring stack:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm dependency update charts/monitoring
helm upgrade --install monitoring ./charts/monitoring --namespace gomin-monitoring \
--set kube-prometheus-stack.grafana.adminPassword="<password>"GitHub Actions deploys using github-actions ServiceAccount — write access only to gomin-apps and gomin-infra.
The manifest is at k8s/bootstrap/ci-rbac.yaml. It creates:
ServiceAccountgithub-actionsingomin-appsClusterRolegomin-deployer— minimum Helm permissions (CRUD on deployments, services, secrets, jobs, HPA, ingresses)RoleBindingin bothgomin-appsandgomin-infraClusterRolegomin-crd-manager+ClusterRoleBinding— CRD and cluster-scoped resource access- A long-lived
Secrettoken (required since Kubernetes 1.24)
chmod +x k8s/bootstrap/generate-ci-kubeconfig.sh
./k8s/bootstrap/generate-ci-kubeconfig.sh "<VDS_IP>" ci-kubeconfig.yaml
cat ci-kubeconfig.yaml # copy output
rm ci-kubeconfig.yaml # delete locallyAdd to GitHub → Settings → Secrets → Actions:
| Name | Value |
|---|---|
KUBECONFIG |
Paste the kubeconfig content |
# Allowed
kubectl auth can-i create deployments \
--as=system:serviceaccount:gomin-apps:github-actions -n gomin-apps # yes
kubectl auth can-i create deployments \
--as=system:serviceaccount:gomin-apps:github-actions -n gomin-infra # yes
# Denied
kubectl auth can-i delete namespaces \
--as=system:serviceaccount:gomin-apps:github-actions # no
kubectl auth can-i get secrets \
--as=system:serviceaccount:gomin-apps:github-actions -n kube-system # noWorkflow: .github/workflows/build-and-deploy.yml
Go to Actions → Build & Deploy → Run workflow:
| Input | Description |
|---|---|
version |
Image tag (e.g. v1.2.0). Empty = use package.json version. |
Build <service> |
Checkbox per service |
Deploy to Kubernetes |
Run helm upgrade after build |
Run DB migrations |
Run Knex migrations before deploy |
setup → compute build + migration matrices
build → parallel per service:
docker build → ghcr.io/<owner>/gomin-<service>:<version>
migrate → (if Deploy + Run migrations checked)
kubectl apply Job → knex migrate:latest
kubectl wait --for=condition=complete --timeout=5m
Job auto-deletes after 10 min
deploy → parallel per service (only if build + migrate succeeded):
helm upgrade --install --atomic --timeout 5m
auto-rollback on failure
| Deploy | Run migrations | Service selected | Result |
|---|---|---|---|
| ✅ | ✅ | ✅ | migrate → deploy |
| ✅ | ✅ | ❌ | skip migrate → deploy |
| ✅ | ❌ | ✅ | skip migrate → deploy (schema must be current) |
| ❌ | any | any | build + push only |
All deployments use RollingUpdate with maxUnavailable: 0, maxSurge: 1 — new pod must pass readiness before old one terminates. --atomic auto-reverts on failure.
# Pod status
kubectl get pods -n gomin-apps
kubectl get pods -n gomin-infra
# Logs
kubectl logs -f deployment/api-gateway -n gomin-apps
# HPA status
kubectl get hpa -n gomin-apps
# Helm history / rollback
helm history auth -n gomin-apps
helm rollback auth -n gomin-apps
# Port-forward MinIO console
kubectl port-forward -n gomin-infra svc/minio 9001:9001
# Migrations run automatically as Helm pre-upgrade hooks.
# To trigger them without a full re-deploy:
helm upgrade auth ./charts/auth --namespace gomin-apps --reuse-values
helm upgrade communication-service ./charts/communication-service --namespace gomin-apps --reuse-valuesReleased as open-source. Developed as a bachelor diploma project.
- Dmytro Lesko — initial work
- Signal Protocol — E2EE design inspiration
- NestJS, Kubernetes, Let's Encrypt communities