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:

  1. Build a portfolio — write raw HTML and CSS, stored in the database, served as a live web page in a sandboxed iframe
  2. Monetize instantly — one-click Stripe Connect onboarding, set up support pages with offerings and tiers, accept tips and recurring subscriptions
  3. Build a community — create "worlds" (like Discord servers) with channels, roles, permissions, and real-time chat
  4. 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 OC001 conflict 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.

  1. DSQL doesn't support pg_advisory_lock — so sqlx::migrate! doesn't work. We built a custom migration runner with INSERT ... ON CONFLICT DO NOTHING on a tracking table.

  2. 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.

  3. Async index builds cause OCC conflicts mid-migrationCREATE INDEX builds asynchronously. If the next DDL fires before it settles, DSQL rejects it with OC001. We wrapped every DDL in retry_on_occ with 10 attempts.

  4. 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.

  5. OCC changes how you write transactions — every transaction must be safely retryable. Side effects (emails, NATS events) moved to after commit. The transaction_with_retry wrapper is used everywhere — no bare BEGIN ... COMMIT blocks.

  6. DynamoDB's 400KB limit forced sort-key-as-string — reversed snowflakes stored as S not N. The reversal formula masks the sign bit (& 0x7FFF_FFFF_FFFF_FFFF).

  7. Snowflake node IDs derived from ECS metadata — Fargate tasks have no stable hostname. We hash ECS_CONTAINER_METADATA_URI_V4 into a 10-bit space (0–1023).

  8. 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.

  9. DSQL does not enforce FOREIGN KEY constraints — so ON DELETE CASCADE is not a safety net. conduit-world's delete_role manually cascades role_permissions inside a transaction (conduit-world/src/store/postgres.rs:1015). Anywhere you'd lean on FK cascade, write the cascade yourself and wrap it in transaction_with_retry.

  10. Cross-service reads are shared, writes are owned — for latency-sensitive reads, services directly query shared tables in the same DSQL cluster. conduit-social JOINs conduit-profile's profiles table (conduit-social/src/store/postgres.rs:303); conduit-profile reads conduit-world's world_users_by_user (conduit-profile/src/store/postgres.rs:577) and conduit-payments's billing_subscriptions (conduit-profile/src/store/postgres.rs:1114); conduit-payments reads usernames (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

  1. Aurora DSQL — distributed PostgreSQL with OCC, no single writer. Used for auth, profiles, portfolios, social graph, communities, payments.
  2. DynamoDB — chat messaging with reversed-snowflake sort keys for newest-first ordering, GSIs for user/channel queries, TransactWriteItems for atomic updates.
  3. 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
    }

Core Schemas

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

Share this project:

Updates