This content was created for the purposes of entering the H0: Hack the Zero Stack hackathon. #H0Hackathon
TL;DR
Onggi is a portfolio-first creator platform where anyone can build custom HTML/CSS portfolios, monetize with one-click Stripe Connect onboarding, and build communities with real-time chat. The backend is 9 Rust microservices on AWS ECS Fargate. The frontend is Next.js 16 on Vercel. Three AWS databases — Aurora DSQL, DynamoDB, and S3 — each chosen for a specific access pattern. The Vercel deployment goes well beyond static hosting: a BFF Route Handler layer, httpOnly session cookies, ISR on public pages, Vercel Cron for SaaS lifecycle, and Vercel Analytics.
What Onggi Does
Onggi lets anyone:
- Build a portfolio — write raw HTML and CSS, stored in the database, served as a live web page in a sandboxed iframe
- Monetize instantly — one-click Stripe Connect onboarding, set up support pages with offerings and tiers, accept tips and recurring subscriptions
- Build a community — create "worlds" (like Discord servers) with channels, roles, permissions, and real-time chat
- Connect socially — follow other creators, block unwanted users, see follower counts
The platform is portfolio-first. Your portfolio IS your homepage. Monetization and community are layered on top. The name comes from onggi (옹기) — Korean earthenware vessels that ferment and mature over time. The metaphor: your presence on the web should mature, not feed an algorithm.
The Architecture
Browser (no tokens in JS — httpOnly cookie only)
│
▼
Vercel Edge Network (Global CDN)
├── proxy.ts — route protection via httpOnly cookie (runs on every request, <1ms)
├── ISR Cache — public profile/community pages edge-cached, revalidate every 60s
└── Next.js 16 Route Handlers (BFF layer)
├── Reads httpOnly session cookie
├── Attaches Authorization: Bearer token
├── Handles 401 token refresh (single-flight, server-side)
└── Forwards to Rust backend (server-to-server, no CORS)
│
▼
AWS NLB :443 (Cloudflare gray DNS → NLB directly)
│
▼
conduit-server (Rust gateway on ECS Fargate, us-east-1, 2 AZs)
│
├── HTTP proxy → auth, profile, world, payments
└── gRPC-over-WebSocket → world, social, channel, media, presence
│
▼
Aurora DSQL + DynamoDB + S3
DNS Routing
DNS is managed by Cloudflare, but routing is gray (DNS-only) — Cloudflare does not proxy traffic. This is deliberate:
- HTTP/API: Browser → Vercel Edge (the CDN) → Route Handler → NLB → Rust. Vercel's edge network is the CDN layer. Cloudflare is just DNS.
- WebSocket: Browser → Cloudflare DNS → NLB → conduit-server. Direct, no intermediary. Gray routing avoids Cloudflare proxy interference with WebSocket upgrades.
- Why not Cloudflare proxy (orange)?: Vercel already provides global edge caching. Double-proxying adds latency without benefit. Gray routing is the clean choice.
The Three-Database Architecture
Different parts of the platform have wildly different data access patterns. No single database handles all of them well. So we split them — each matched to the pattern it dominates. The rule we followed:
A database earns its place only if removing it would break a specific access pattern that no other database in the stack handles better.
The Access-Pattern Decision Matrix
Before choosing a database, we enumerated every read/write pattern the platform needs, then scored each candidate against each one. A ✅ means the database is the best fit for that pattern — not merely "can survive it."
| Access Pattern | Requirement | Aurora DSQL | DynamoDB | S3 | Plain Aurora (Postgres) | MongoDB | Redis-only |
|---|---|---|---|---|---|---|---|
| User auth, profiles, transactions | Atomic multi-row writes, FKs, joins | ✅ Native ACID, no locks | ❌ No joins, no FKs | ❌ Object store | ✅ ACID | ⚠️ Document-level atomicity only | ❌ Volatile |
| Social graph (follows, counters) | Concurrent writes to same row | ✅ OCC retry, no deadlocks | ✅ Atomic counters | ❌ | ⚠️ Row locks → deadlocks | ⚠️ Embedded docs bloat | ❌ |
| Raw HTML/CSS portfolios (TEXT up to 4MB) | Store + serve large text, keep relational link | ✅ TEXT columns, joined to user | ⚠️ 400KB item limit | ✅ But loses relational link | ✅ | ✅ | ❌ |
| Stripe webhook idempotency | Dedup table + atomic update | ✅ ON CONFLICT DO NOTHING in txn |
⚠️ Conditional write only | ❌ | ✅ | ⚠️ | ❌ |
| Community permissions (bitmask JOINs) | Complex multi-table joins | ✅ Native JOINs | ❌ Can't join | ❌ | ✅ | ❌ | ❌ |
| Real-time chat messages | Write-heavy, time-ordered, horizontally scalable | ⚠️ Single-writer bottleneck at scale | ✅ Horizontal, single-digit ms | ❌ | ❌ Single writer | ⚠️ Scale ceiling | ✅ But volatile |
| Chat history pagination (newest-first) | Efficient range scans | ⚠️ B-tree scans cost more | ✅ Reversed SK sort key | ❌ | ⚠️ | ⚠️ | ❌ |
| Media (avatars, banners, uploads) | Large blobs, CDN delivery | ❌ Not for blobs | ❌ 400KB limit | ✅ Presigned URLs | ❌ | ✅ | ❌ |
The takeaway is in the last row of every column: no single database passes every row. That is why we use three — each matched to the pattern it dominates.
| Feature | Access Pattern | Database |
|---|---|---|
| User auth, profiles, portfolios | Relational, transactional | Aurora DSQL |
| Creator payments, subscriptions | ACID, Stripe webhook idempotency | Aurora DSQL |
| Community permissions, roles | Complex joins, bitmask checks | Aurora DSQL |
| Real-time chat messages | Write-heavy, time-ordered, horizontally scalable | DynamoDB |
| Portfolio media, avatars, banners | Large blobs, CDN delivery | S3 |
Aurora DSQL — The Relational Backbone
Aurora DSQL is a distributed, PostgreSQL-compatible database with no single writer. This was the most exciting database to work with — distributed SQL that doesn't need read replicas or failover.
DSQL stores everything relational: user identity, profiles, portfolios (raw HTML/CSS as TEXT columns), social graph, communities, roles, permissions, payments, and Stripe webhook events.
Why DSQL — not Aurora Postgres, not MongoDB:
vs. Aurora Postgres — the single-writer bottleneck. Standard Aurora Postgres funnels all writes through one instance. At scale, concurrent follow storms, webhook processing, and community joins all serialize on row locks. DSQL has no single writer — every node accepts reads and writes. Concurrency is handled by Optimistic Concurrency Control (OCC): transactions read, compute, and commit — if another transaction modified the same rows, yours gets an
OC001conflict and retries automatically. No locks, no deadlocks, no read replica lag.vs. MongoDB — losing relational integrity. Our permission system requires a 4-table JOIN (
worlds → world_users → world_roles → world_effective_permissions) with bitmask aggregation. MongoDB has no server-side JOINs. DSQL does it in one query:
SELECT permission_bits FROM world_effective_permissions
WHERE world_id = $1 AND user_id = $2;
The portfolio feature: Creators write raw HTML and CSS, stored directly in DSQL as TEXT columns. No templating engine, no CMS — just creative freedom, served as a live web page. Portfolios are rendered in sandboxed iframes with strict CSP headers blocking inline scripts.
OCC in practice — the follow graph:
conn.transaction_with_retry(None, |tx| {
Box::pin(async move {
// Insert follow edge (idempotent — safe to retry)
sqlx::query(
"INSERT INTO follows (followed_id, follower_id, since)
VALUES ($1, $2, clock_timestamp())
ON CONFLICT DO NOTHING",
)
.bind(followed_id).bind(follower_id)
.execute(&mut **tx).await?;
// Atomically increment counter
sqlx::query(
"UPDATE follow_counters SET followers_count = followers_count + 1
WHERE user_id = $1",
)
.bind(followed_id).execute(&mut **tx).await?;
Ok(())
})
}).await?;
When 10,000 users follow the same creator during a viral moment, DSQL lets them all proceed concurrently — losers retry in microseconds. No deadlock detection, no lock manager, no replica lag.
DynamoDB — Real-Time Chat
Chat messages are write-heavy, time-ordered, and need to scale horizontally. DynamoDB was the best fit.
Why DynamoDB — not DSQL, not Redis, not MongoDB:
- vs. DSQL — write throughput ceiling. Even with no single writer, DSQL has transaction validation overhead on every commit. Chat messages are independent, append-only, and rarely updated — the transactional guarantee is wasted overhead. DynamoDB gives single-digit-millisecond writes at any scale.
- vs. Redis — persistence. Redis is faster on a single node, but it's in-memory. Two years of chat history cannot fit in RAM without complex sharding. DynamoDB scales horizontally with zero operational intervention. We use Redis (DragonflyDB) for presence (who's online), where volatility is fine.
- vs. MongoDB — sort key efficiency. By storing a reversed snowflake as the sort key (
message_id_reversed = i64::MAX - message_id), we get efficient newest-first retrieval and cursor pagination using DynamoDB's query ordering. No secondary index needed:
pub fn generate_reversed_snowflake_id() -> Result<i64, Status> {
let id = generate_snowflake_id()?;
Ok(((i64::MAX) - id) & 0x7FFF_FFFF_FFFF_FFFF)
}
S3 — Direct-to-Storage Media Uploads
Media should never pass through your backend. S3 presigned URLs let the client upload directly:
let presigned = client
.put_object()
.bucket(&bucket)
.key(&key)
.content_type(&content_type)
.presigned(PresigningConfig::expires_in(Duration::from_secs(3600))?)
.await?;
DSQL TEXT columns cap at 4MB. DynamoDB items cap at 400KB. S3 has no practical limit and serves via CDN. Presigned URLs mean the backend never touches media bytes — Rust services stay at 256 CPU / 512 MiB.
How We Use Vercel — The Full Stack
The frontend is deployed on Vercel (Next.js 16.2.9, React 19.2, App Router). But it goes far beyond static hosting. Here's every Vercel platform feature we use and why.
1. httpOnly Session Cookie via Route Handlers (Security)
Tokens (access + refresh) are stored in an httpOnly, Secure, SameSite=Lax cookie set by a server-side Route Handler. Client-side JavaScript cannot read the raw tokens — eliminating the entire class of XSS-based token theft.
Before: The session cookie was non-httpOnly, set client-side via document.cookie. The refresh token was readable by any JS. A real security weakness.
After:
Login: Browser → POST /api/auth/session (Route Handler)
→ Route Handler calls Rust /auth/login
→ Rust returns { token, refresh_token }
→ Route Handler sets httpOnly cookie
→ Returns { success: true } (no tokens in response body)
API calls: Browser → /api/profile/* (Route Handler)
→ Route Handler reads httpOnly cookie
→ Attaches Authorization: Bearer <token>
→ Calls Rust backend (server-to-server)
→ Returns response (no tokens exposed)
401 refresh: Route Handler → POST /auth/refresh (Rust)
→ Gets new tokens → updates httpOnly cookie → retries request
→ Single-flight: concurrent 401s coalesce into one refresh call
The WebSocket problem — solved without backend changes: The WS gateway client needs a token for the IDENTIFY payload, but client JS can't read the httpOnly cookie. Solution: GET /api/auth/token reads the cookie, refreshes if expired, and returns a short-lived access token. The token exists in JS memory only for the WS session duration — never persisted to localStorage.
2. Route Handler BFF Layer (Architecture)
All API calls go through Next.js Route Handlers running as Vercel Functions (Fluid Compute). The browser never calls the Rust backend directly for HTTP.
Before: Browser → (CORS) → Rust backend After: Browser → Vercel Function (Route Handler) → Rust backend (no CORS)
Four catch-all Route Handlers proxy to the Rust services: /api/auth/*, /api/profile/*, /api/world/*, /api/payments/*. Each reads the httpOnly cookie, attaches the Bearer token, handles 401 refresh, and forwards server-to-server.
This shows deliberate architecture: edge → BFF → microservices gateway → services → DB. The BFF layer enables server-side caching, auth injection, and response normalization. It also simplifies the backend's CORS config — CORS is no longer needed for HTTP calls (only for the WebSocket gateway).
3. ISR + React Server Components on Public Pages (Design + Technical)
Public profile pages (/u/[username]) and community pages (/g/[groupname]) are server-rendered with Incremental Static Regeneration. Data is fetched server-side in RSC and edge-cached for 60 seconds.
// app/src/app/(public)/u/[username]/page.tsx
export const revalidate = 60;
export const dynamicParams = true;
export default async function Page({ params }) {
const { username } = await params;
const initialData = await getPublicProfile(username); // server-side, ISR-cached
return <PublicProfileView username={username} initialData={initialData} />;
}
Since Cloudflare is gray (DNS-only), Vercel's edge network is the only CDN in front of the application. ISR is what makes public content fast globally — cache hits serve instantly (<50ms), cache misses trigger a background revalidation.
| Content Type | Strategy | Why |
|---|---|---|
| Public profiles/communities | ISR (revalidate: 60s) | Public, cacheable at edge |
| Authenticated pages | Dynamic SSR | User-specific, cannot be cached |
| WebSocket | Direct to NLB | Real-time, cannot be proxied |
4. Vercel Cron Jobs (SaaS Readiness)
// app/src/vercel.ts
export const config = {
crons: [
{ path: "/api/cron/cleanup-sessions", schedule: "*/15 * * * *" },
{ path: "/api/cron/subscription-expiry", schedule: "0 9 * * *" },
],
};
- Session cleanup (every 15 min): Calls the Rust presence service to remove stale online sessions
- Subscription expiry (daily 09:00 UTC): Calls the Rust payments service to expire subscriptions, downgrade users, send notifications
Each cron Route Handler is protected by a CRON_SECRET bearer token — Vercel sends this automatically.
5. Loading + Error Boundaries (Design Maturity)
loading.tsx and error.tsx files in the (public) and (app) route segments. With RSC streaming, the page shell loads instantly, then content streams in as the server resolves data. If data fetching fails, the error boundary catches it and renders a recovery UI.
6. Vercel Analytics + Speed Insights (Shippability)
@vercel/analytics and @vercel/speed-insights are auto-injected on every page via the root layout. Zero config, free, provides Core Web Vitals monitoring.
The Backend — 9 Rust Microservices
| Service | Protocol | Database | Responsibility |
|---|---|---|---|
| conduit-server | HTTP + gRPC | — | API Gateway, JWT verify, routing |
| conduit-auth | HTTP | DSQL | Auth, OAuth, JWT, OTP, sessions |
| conduit-profile | HTTP | DSQL | Profiles, portfolios, usernames, offerings |
| conduit-world | gRPC + HTTP | DSQL | Communities, roles, permissions, bans |
| conduit-social | gRPC | DSQL | Follow graph, blocks, social counts |
| conduit-channel | gRPC | DynamoDB | Chat, messages, reactions, DMs, pins |
| conduit-media | gRPC | S3 | Presigned URLs, media management |
| conduit-presence | gRPC | Redis | Online status, WebSocket presence |
| conduit-payments | HTTP | DSQL | Stripe Connect, tips, subscriptions |
All deployed on ECS Fargate across 2 AZs (us-east-1a + us-east-1b), behind an NLB with Cloud Map service discovery and VPC endpoints for S3 + DynamoDB.
What We Learned — Real Engineering Gotchas
These are things that actually broke, errored, or required redesign. Every item is backed by code in the repository.
DSQL doesn't support
pg_advisory_lock— sosqlx::migrate!doesn't work. We built a custom migration runner withINSERT ... ON CONFLICT DO NOTHINGon a tracking table.DSQL rejects multi-statement DDL batches — "multiple ddl statements not supported in a transaction." We wrote a
split_ddl_statements()function to execute each DDL individually.Async index builds cause OCC conflicts mid-migration —
CREATE INDEXbuilds asynchronously. If the next DDL fires before it settles, DSQL rejects it withOC001. We wrapped every DDL inretry_on_occwith 10 attempts.The tracking-table INSERT also races — two pods both pass
is_applied(), both run DDL, then race on the INSERT. The loser crashes without retry. Fix:retry_on_occ+ON CONFLICT DO NOTHING.OCC changes how you write transactions — every transaction must be safely retryable. Side effects (emails, NATS events) moved to after commit. The
transaction_with_retrywrapper is used everywhere — no bareBEGIN ... COMMITblocks.DynamoDB's 400KB limit forced sort-key-as-string — reversed snowflakes stored as
SnotN. The reversal formula masks the sign bit (& 0x7FFF_FFFF_FFFF_FFFF).Snowflake node IDs derived from ECS metadata — Fargate tasks have no stable hostname. We hash
ECS_CONTAINER_METADATA_URI_V4into a 10-bit space (0–1023).Moving tokens to httpOnly cookies broke the WebSocket client — solved with a short-lived token endpoint (
GET /api/auth/token) that reads the cookie and returns a token for the WS IDENTIFY payload. No backend change needed.DSQL does not enforce FOREIGN KEY constraints — so
ON DELETE CASCADEis not a safety net.conduit-world'sdelete_rolemanually cascadesrole_permissionsinside a transaction (conduit-world/src/store/postgres.rs:1015). Anywhere you'd lean on FK cascade, write the cascade yourself and wrap it intransaction_with_retry.Cross-service reads are shared, writes are owned — for latency-sensitive reads, services directly query shared tables in the same DSQL cluster.
conduit-socialJOINsconduit-profile'sprofilestable (conduit-social/src/store/postgres.rs:303);conduit-profilereadsconduit-world'sworld_users_by_user(conduit-profile/src/store/postgres.rs:577) andconduit-payments'sbilling_subscriptions(conduit-profile/src/store/postgres.rs:1114);conduit-paymentsreadsusernames(conduit-payments/src/store/postgres.rs:542). Write ownership is enforced at the application layer; reads are shared to avoid a network hop.
Production Readiness
| Concern | Mitigation |
|---|---|
| Multi-AZ | Tasks across us-east-1a + us-east-1b |
| Service discovery | AWS Cloud Map private DNS namespace |
| VPC security | S3 + DynamoDB VPC gateway endpoints |
| IAM | Least-privilege: DSQL, DynamoDB, S3, SES scoped |
| Session security | httpOnly + Secure + SameSite=Lax cookies, ES256 JWT, JWKS rotation |
| Container security | ECR scan-on-push on all repos |
| Observability | tracing JSON logs → CloudWatch, Vercel Analytics + Speed Insights |
| Health checks | Liveness + readiness on every service |
| Brute-force | Login lockout (10 attempts), OTP lockout (5 attempts) |
| Portfolio XSS | Sandboxed iframe, strict CSP blocking inline scripts |
Databases Used
- Aurora DSQL — distributed PostgreSQL with OCC, no single writer. Used for auth, profiles, portfolios, social graph, communities, payments.
- DynamoDB — chat messaging with reversed-snowflake sort keys for newest-first ordering, GSIs for user/channel queries, TransactWriteItems for atomic updates.
- S3 — presigned URL uploads for direct client-to-S3 media transfer.
Database Schemas
Aurora DSQL — Core Relational Schema
erDiagram
users ||--o{ refresh_tokens : has
users ||--o{ email_verifications : has
users ||--o{ oauth_providers : has
users ||--o{ login_audit_log : audited
users ||--|| profiles : owns
users ||--|| usernames : claims
usernames ||--o{ username_history : tracks
profiles ||--o{ social_links : has
profiles ||--|| portfolios : has
profiles ||--o{ creator_support_pages : has
profiles ||--o{ creator_offerings : has
creator_offerings ||--o{ offering_tiers : has
users ||--o{ follows : follows
users ||--o{ followers_by_user : followed_by
users ||--|| follow_counters : counted
users ||--o{ blocks : blocks
users ||--o{ blocked_by : blocked_by
users ||--o{ worlds : owns
worlds ||--o{ world_names : named
worlds ||--o{ world_users : has_members
worlds ||--o{ world_settings : configured
worlds ||--o{ world_roles : has_roles
world_roles ||--o{ world_role_assignments : assigned
worlds ||--o{ world_effective_permissions : cached_perms
worlds ||--o{ world_bans : bans
worlds ||--o{ world_invites : invites
worlds ||--o{ world_portfolios : portfolio
worlds ||--o{ world_audit_log : audited
worlds ||--o{ world_join_requests : requests
users ||--o{ tips : gives
users ||--o{ tips : receives
users ||--o{ creator_payment_methods : has
users ||--|| billing_customers : is
users ||--o{ billing_subscriptions : subscribes
users ||--o{ creator_subscriptions : supports
billing_plans ||--o{ billing_subscriptions : plan
creator_offerings ||--o{ creator_subscriptions : offering
offering_tiers ||--o{ creator_subscriptions : tier
users {
bigint user_id PK
text email UK
text password_hash
int2 phone_verify_status
int2 email_verify_status
}
profiles {
bigint user_id PK
text username
text display_name
text bio
text avatar_url
text banner_url
}
portfolios {
bigint user_id PK
text html_content
text css_content
bool published
}
worlds {
bigint world_id PK
text name
bigint owner_id FK
int2 visibility
bigint member_count
jsonb tags
}
tips {
bigint id PK
bigint tipper_user_id FK
bigint creator_user_id FK
bigint amount_cents
text status
bool is_recurring
}

DynamoDB — Channel & Messaging Schema
erDiagram
conduit_channels ||--o{ conduit_messages : contains
conduit_channels ||--o{ conduit_pins : has
conduit_channels ||--o{ conduit_dm_channels : has
conduit_messages ||--o{ conduit_reactions : has
conduit_channels {
text channel_id PK
text world_id FK
text name
text channel_type
int position
bool archived
int message_count
}
conduit_messages {
text channel_id PK
text message_id_reversed "sort key"
text message_id
text author_id
text content
int2 expires_at
bool deleted
}
conduit_reactions {
text message_id PK
text user_emoji "sort key"
text user_id
text emoji
}
conduit_dm_channels {
text channel_id PK
text user_a
text user_b
}
conduit_pins {
text channel_id PK
text message_id "sort key"
text pinned_by
}
Onggi is a portfolio-first platform where anyone can build a custom online presence, connect with communities, and monetize what they create or share.
Built for the H0: Hack the Zero Stack hackathon. #H0Hackathon github-repo: https://github.com/thou-sif/Onggi-backend Live site: https://onggi.space
Note: The application is currently running in test mode because Stripe has limitations for businesses operating from India, and Amazon SES has not yet granted production access for email sending.
Next Steps
There are a few things that the backend supports but the frontend doesn't yet, so those will be covered as next steps, And Once I get the Stripe access and the Amaon SES production access, I'll be launching this with Blogs and events ( which the backends are already done ).
So as of the next steps for Onggi, it's polish it up, and market. till then in a test environment.
Built With
- amazon-dynamodb
- aurora-dsql
- ecs
- rust
- s3
- v0
- verce

Log in or sign up for Devpost to join the conversation.