A team messaging application built with Go and React.
- Backend: Go (single binary with embedded frontend)
- Frontend: React + TypeScript + Vite + shadcn/ui
- Database: DynamoDB (single-table design)
- Cache/PubSub: Redis
- Real-time: WebSocket + Redis pub/sub
- Auth: OIDC SSO + email invites (guests), JWT sessions
- Docker & Docker Compose
docker compose up --buildThis builds the app using the production Dockerfile (frontend + Go binary) and starts it alongside DynamoDB Local and Redis.
- App: http://localhost:8080 (serves both API and frontend)
The DynamoDB table is created automatically on first start. The first user to log in via SSO is automatically promoted to admin.
# Or use the Makefile shortcuts:
make dev # foreground (same as docker compose up --build)
make dev-up # background
make dev-down # stop all
make dev-logs # tail logsThe app uses OpenID Connect (OIDC) for authentication. The OIDC redirect URL is always {BASE_URL}/auth/oidc/callback (e.g. http://localhost:8080/auth/oidc/callback). Register this as the redirect URI in your identity provider.
- Go to Azure Portal > Microsoft Entra ID > App registrations > New registration
- Set the Redirect URI to
http://localhost:8080/auth/oidc/callback(or your production URL) - Under Certificates & secrets, create a new Client secret and copy the value
- Note the Application (client) ID and Directory (tenant) ID from the Overview page
- Set these environment variables:
OIDC_ISSUER=https://login.microsoftonline.com/{tenant-id}/v2.0
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
BASE_URL=http://localhost:8080- Go to Google Cloud Console > APIs & Services > Credentials > Create OAuth client ID
- Set Authorized redirect URIs to
http://localhost:8080/auth/oidc/callback - Set these environment variables:
OIDC_ISSUER=https://accounts.google.com
OIDC_CLIENT_ID=your-client-id.apps.googleusercontent.com
OIDC_CLIENT_SECRET=your-client-secret
BASE_URL=http://localhost:8080Any provider that supports OpenID Connect Discovery (.well-known/openid-configuration) will work. Set OIDC_ISSUER to the issuer URL, and register {BASE_URL}/auth/oidc/callback as the redirect URI.
To send invite links via email, configure the following environment variables:
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASS=your-smtp-password
SMTP_FROM=noreply@yourcompany.comWhen SMTP is not configured, invite links are logged to the server console.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port |
ENV |
development |
development or production |
BASE_URL |
http://localhost:8080 |
Application base URL (used to derive OIDC redirect URL) |
OIDC_ISSUER |
- | OIDC provider issuer URL |
OIDC_CLIENT_ID |
- | OIDC client ID |
OIDC_CLIENT_SECRET |
- | OIDC client secret |
JWT_SECRET |
dev-secret-change-me (dev only) |
JWT signing secret (openssl rand -base64 48) |
AWS_REGION |
us-east-1 |
AWS region |
DYNAMODB_TABLE |
ex |
DynamoDB table name (single-table design — see below) |
DYNAMODB_ENDPOINT |
- | DynamoDB endpoint (set for local dev) |
REDIS_URL |
redis://localhost:6379 |
Redis connection URL |
SMTP_HOST |
- | SMTP server hostname |
SMTP_PORT |
587 |
SMTP server port |
SMTP_USER |
- | SMTP username |
SMTP_PASS |
- | SMTP password |
SMTP_FROM |
- | Sender email address for invites |
# Build production binary (includes embedded frontend)
make build
# Build Docker image
make dockerDespite the name, DYNAMODB_TABLE configures a single table, not a prefix. The app follows the DynamoDB single-table design: every entity (users, channels, conversations, messages, memberships, invites, refresh tokens, settings, …) lives in one table, distinguished by composite PK/SK prefixes (USER#, CHAN#, MSG#, …) plus two GSIs.
The dev stack creates the table for you on first start — EnsureTable runs only when ENV=development and is a no-op when the table already exists.
In production the binary will not create or modify the table; that responsibility lives with your infrastructure tooling so the running app needs only dynamodb:GetItem, dynamodb:Query, dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:DeleteItem, dynamodb:TransactWriteItems, dynamodb:BatchWriteItem — never CreateTable.
Create the table once with the AWS CLI (replace ex with your DYNAMODB_TABLE value if different):
aws dynamodb create-table \
--table-name ex \
--billing-mode PAY_PER_REQUEST \
--attribute-definitions \
AttributeName=PK,AttributeType=S \
AttributeName=SK,AttributeType=S \
AttributeName=GSI1PK,AttributeType=S \
AttributeName=GSI1SK,AttributeType=S \
AttributeName=GSI2PK,AttributeType=S \
AttributeName=GSI2SK,AttributeType=S \
--key-schema \
AttributeName=PK,KeyType=HASH \
AttributeName=SK,KeyType=RANGE \
--global-secondary-indexes '[
{
"IndexName": "GSI1",
"KeySchema": [
{"AttributeName": "GSI1PK", "KeyType": "HASH"},
{"AttributeName": "GSI1SK", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
},
{
"IndexName": "GSI2",
"KeySchema": [
{"AttributeName": "GSI2PK", "KeyType": "HASH"},
{"AttributeName": "GSI2SK", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}
]'
# Enable TTL so expired refresh tokens / invites are auto-evicted.
aws dynamodb update-time-to-live \
--table-name ex \
--time-to-live-specification "Enabled=true, AttributeName=ttl"If you prefer Terraform / CloudFormation / CDK, replicate the same shape: PK+SK primary key, two GSIs (GSI1/GSI2) each with *PK+*SK and ProjectionType=ALL, and a TTL on the ttl attribute.
A parent partition (PK = CHAN#<id> or PK = CONV#<id>) holds the parent's messages plus a few index rows that let the sidebar's "pinned" and "files" panes load in O(pinned) / O(files-shared) instead of scanning every message:
SK prefix |
Item | Lifecycle |
|---|---|---|
MSG# |
Message | Created on send; soft-deleted on delete. |
PIN# |
Pinned-message ref | Written when a message is pinned; removed on unpin or message delete. Listed by Query SK begins_with. |
FILE# |
Shared-file ref | Upserted per (parent, attachmentID) on every send/edit that references the file; removed on delete only when the row's MessageID still points at the deleted message (re-shares survive). |
PIN# and FILE# rows live alongside the MSG# rows in the same DDB partition, so listing them is a single Query against PK + begins_with(SK, "PIN#") (no GSI). The dedicated row also lets ListPinned / ListFiles skip the legacy 1000-message scan that previously bounded both queries' accuracy.
Existing deployments must run this migration once after upgrading — ListPinned and ListFiles read exclusively from PIN# / FILE# rows when the index is wired (always, in production), so pre-rollout messages whose pins and attachments never got indexed will show up as empty Files / Pinned panels until the backfill runs.
# Preview (default — no writes)
go run ./cmd/migrate-parent-index --dry-run
# Apply: writes one PIN# row per pinned message and one FILE# row
# per shared attachment (latest sharer wins). Idempotent; safe to
# re-run after a crash. Requires the same AWS_REGION /
# DYNAMODB_TABLE / DYNAMODB_ENDPOINT env vars as the server.
go run ./cmd/migrate-parent-index --applyLocal dev (docker compose) — DynamoDB Local lives inside the dynamodb container; point the migrator at it from the host:
# Preview against the running stack:
AWS_REGION=us-east-1 \
AWS_ACCESS_KEY_ID=dummy \
AWS_SECRET_ACCESS_KEY=dummy \
DYNAMODB_TABLE=ex \
DYNAMODB_ENDPOINT=http://localhost:8000 \
go run ./cmd/migrate-parent-index --dry-run
# Same shape with --apply when you're ready to write.
AWS_REGION=us-east-1 \
AWS_ACCESS_KEY_ID=dummy \
AWS_SECRET_ACCESS_KEY=dummy \
DYNAMODB_TABLE=ex \
DYNAMODB_ENDPOINT=http://localhost:8000 \
go run ./cmd/migrate-parent-index --applyThe AWS credentials are mandatory but their values don't matter — DynamoDB Local accepts anything; the SDK just refuses to start without a key pair set.
The script is read-mostly: it scans every parent's MSG# rows once and writes ~1 index row per pinned message + ~1 per shared attachment. Storage impact is bounded by parents × (pinned_messages + unique_attachments) — typically negligible vs. message volume.
- Real-time: WebSocket for server-to-client push, REST for everything else
- Stateless servers: Redis pub/sub enables horizontal scaling
- DynamoDB single-table: All entities in one table with composite keys
- Embedded SPA: Frontend built into the Go binary for single-artifact deployment
- First user is admin: The first person to log in via SSO gets the admin role