Skip to Content

You’re at the correct inflection point to fix this before it calcifies.

What you have now is a bootstrap registry, not an upload system. The goal on a VPS is to move from:

“Emotes are code + files” to “Emotes are data + assets”

Below is the right way to handle emote uploads on a VPS, without jumping prematurely to S3, while keeping a clean migration path.


0️⃣ Current state (what it actually is)

Right now you have three coupled concerns:

  1. Asset storage

    • Files committed to /public/assets/emojis/
  2. Registry

    • Hard-coded LOCAL_EMOTES array
  3. Authorization

    • Inline requires logic

This causes real problems:

  • deploy required to add/remove emotes
  • no moderation lifecycle
  • no auditability
  • no way to scale or cache sanely
  • chat backend is pretending to be a CMS

This is fine for bootstrap, but it must be explicitly transitional.


1️⃣ Correct target model (VPS-friendly)

On a VPS, the cleanest architecture is:

Control Plane ├─ handles uploads ├─ writes DB records └─ writes files to disk Chat Backend ├─ reads emote metadata from DB └─ never touches filesystem directly NGINX └─ serves emote assets as static files

No S3 required yet. No Docker volumes required yet. Just separation of authority.


2️⃣ Filesystem layout (do this first)

Stop committing emotes into your repo.

Create a runtime asset directory:

/srv/mars/assets/ └── emotes/ ├── global/ ├── channel/ └── user/

Example:

/srv/mars/assets/emotes/global/marsu.webp /srv/mars/assets/emotes/global/owner.webp

Rules

  • This directory is not in git
  • Backed up separately
  • Writable by Control Plane only
  • Readable by NGINX

3️⃣ NGINX: serve emotes as static assets

Add a dedicated location block:

location /emotes/ { alias /srv/mars/assets/emotes/; access_log off; expires 30d; add_header Cache-Control "public, immutable"; }

Resulting URLs:

https://mars.marsuvesvex.xyz/emotes/global/marsu.webp

This replaces:

/public/assets/emojis/

4️⃣ Database becomes the source of truth

Introduce an Emote table.

Minimal schema (Prisma)

model Emote { id String @id @default(cuid()) name String @unique scope EmoteScope path String // relative path under /emotes mimeType String width Int height Int requires Json? // permissions (sub, roles, etc) status EmoteStatus @default(PENDING) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum EmoteScope { GLOBAL CHANNEL } enum EmoteStatus { PENDING APPROVED REJECTED }

Key point:

  • DB defines what exists
  • Filesystem just stores bytes

5️⃣ Control Plane owns uploads (authoritative)

Upload flow:

Admin uploads emote → validate image → write file to /srv/mars/assets/emotes/… → insert DB row (PENDING) → moderation → APPROVED

Never let chat backend write files.


6️⃣ Chat backend: replace hard-coded registry

Your existing provider is already shaped correctly — it just needs to stop being in-memory.

Replace this:

const LOCAL_EMOTES: LocalEmote[] = [ ... ];

With this:

export async function fetchLocalEmotes(): Promise<LocalEmote[]> { const emotes = await prisma.emote.findMany({ where: { status: "APPROVED" }, }); return emotes.map(e => ({ name: e.name, url: `https://mars.marsuvesvex.xyz/emotes/${e.path}`, requires: e.requires ?? undefined, })); }

That’s it.

No other chat logic needs to change.


7️⃣ Permissions stay data-driven

Your current requires shape is good. Keep it.

requires?: { sub?: boolean; vip?: boolean; roles?: string[]; };

Just store it as JSON in the DB.

This allows:

  • paid emotes
  • role-locked emotes
  • temporary entitlements later

8️⃣ Caching (important on VPS)

You do not want to hit the DB on every message.

Do this:

  • Cache emote registry in memory (chat backend)

  • Refresh on:

    • startup
    • admin “invalidate emotes” action
    • periodic TTL (e.g. 60s)
let cached: LocalEmote[] = []; let lastFetch = 0; export async function fetchLocalEmotes() { if (Date.now() - lastFetch > 60_000) { cached = await loadFromDb(); lastFetch = Date.now(); } return cached; }

9️⃣ Migration plan (safe, incremental)

Phase 1

  • Keep existing hard-coded emotes
  • Add DB + filesystem support in parallel

Phase 2

  • Migrate existing emotes into DB
  • Move files to /srv/mars/assets/emotes
  • Update URLs

Phase 3

  • Delete LOCAL_EMOTES
  • Make DB mandatory

No big bang.


10️⃣ Explicit non-goals (for now)

Do not add yet:

  • S3 / R2
  • signed URLs
  • dynamic resizing
  • per-message emote resolution
  • client uploads

Those come later, cleanly, once this foundation exists.


11️⃣ Documentation placement (important)

This should be documented in three places:

docs/ ├─ domains/assets/emotes/ │ ├─ storage.mdx ← filesystem + nginx │ ├─ schema.mdx ← DB model │ ├─ lifecycle.mdx ← pending → approved ├─ control-plane/workflows/ │ └─ emote-upload.mdx ← upload logic ├─ chat-backend/ │ └─ emotes.mdx ← consumption rules

That prevents future confusion about “who owns emotes”.


Final reality check

What you are building is effectively:

  • a CMS for chat assets
  • running on a single VPS
  • with a clean migration path to object storage

This approach:

  • works today
  • scales later
  • doesn’t require rewriting chat logic
  • doesn’t block you on infra decisions

If you want next, I can:

  • design the exact Control Plane upload endpoint
  • add image validation rules
  • spec emote moderation UI
  • show how to migrate to S3/R2 later without URL breakage

Say which.

Short answer: yes, but not by hard-coding “pick logic” in the frontend.

You want explicit size variants, stable URLs, and backend-defined metadata, with the frontend doing selection, not decision-making.

Below is the correct, future-proof model for emote sizing on a VPS.


1️⃣ The correct mental model

An emote is one logical asset with multiple physical representations.

Do not treat 1x, 2x, 3x as separate emotes. Do not let the frontend invent sizes.

Instead:

Emote (logical) ├─ variants (physical) │ ├─ 1x │ ├─ 2x │ └─ 3x

The backend owns what exists. The frontend chooses what to render.


2️⃣ File & URL layout (VPS-friendly, CDN-ready)

Use predictable paths, not query params.

/emotes/global/marsu/ ├─ 1x.webp ├─ 2x.webp └─ 3x.webp

Public URLs:

https://mars.marsuvesvex.xyz/emotes/global/marsu/1x.webp https://mars.marsuvesvex.xyz/emotes/global/marsu/2x.webp https://mars.marsuvesvex.xyz/emotes/global/marsu/3x.webp

Why this is correct

  • cacheable forever
  • CDN-friendly
  • trivial to migrate to S3/R2 later
  • human-inspectable
  • no runtime resizing

3️⃣ Database schema (this matters)

This gives you full control later.

model Emote { id String @id @default(cuid()) name String @unique scope EmoteScope requires Json? status EmoteStatus createdAt DateTime @default(now()) variants EmoteVariant[] } model EmoteVariant { id String @id @default(cuid()) emoteId String emote Emote @relation(fields: [emoteId], references: [id]) scale Int // 1, 2, 3 path String // global/marsu/2x.webp width Int height Int mimeType String @@unique([emoteId, scale]) }

This avoids JSON blobs and lets you:

  • add animated variants later
  • add AVIF/WebP splits later
  • add mobile-specific variants later

4️⃣ What the chat backend returns (important)

The backend returns metadata, not a single URL.

type EmotePayload = { name: string; variants: { scale: 1 | 2 | 3; url: string; width: number; height: number; }[]; requires?: { sub?: boolean; vip?: boolean; roles?: string[]; }; };

Example:

{ "name": ":MARSU:", "variants": [ { "scale": 1, "url": "/emotes/global/marsu/1x.webp", "width": 28, "height": 28 }, { "scale": 2, "url": "/emotes/global/marsu/2x.webp", "width": 56, "height": 56 }, { "scale": 3, "url": "/emotes/global/marsu/3x.webp", "width": 84, "height": 84 } ] }

The backend does not choose which one is used.


5️⃣ Frontend selection logic (simple, deterministic)

The frontend selects, but does not invent.

Rule of thumb

  • Use devicePixelRatio
  • Clamp to available variants
function pickVariant(variants, dpr = window.devicePixelRatio) { const target = dpr >= 3 ? 3 : dpr >= 2 ? 2 : 1; return ( variants.find(v => v.scale === target) ?? variants.find(v => v.scale === 1) ); }

This gives you:

  • crisp rendering
  • predictable behavior
  • no layout jumps

6️⃣ Rendering (do not overthink it)

Use <img>, not <picture> yet.

const variant = pickVariant(emote.variants); <img src={variant.url} width={variant.width / variant.scale} height={variant.height / variant.scale} loading="lazy" decoding="async" />

This keeps:

  • layout stable
  • GPU cost low
  • animations smooth

7️⃣ Upload & generation strategy (Control Plane)

On upload:

  1. Accept one high-quality source (e.g. 112×112)

  2. Generate:

    • 1x (28×28)
    • 2x (56×56)
    • 3x (84×84)
  3. Store all three

  4. Write DB rows for variants

Never rely on runtime resizing.


8️⃣ What NOT to do (these are traps)

❌ Store only one size and CSS-scale ❌ Use query params (?size=2x) ❌ Let frontend guess URLs ❌ Generate on request ❌ Hard-code size logic in chat backend

All of these will bite you later.


9️⃣ Migration to CDN / S3 later (zero breakage)

Because your URLs are already stable:

/emotes/global/marsu/2x.webp

You can later:

  • move storage to S3/R2
  • put Cloudflare in front
  • keep URLs identical

No frontend or backend changes required.


10️⃣ Final recommendation (lock this in)

Yes, have 1x / 2x / 3x URLs Yes, let frontend select No, do not make frontend authoritative No, do not resize dynamically

This is exactly how Twitch, 7TV, and Discord solve it.


If you want next

I can:

  • design exact upload validation rules
  • show sharp/libvips config for resizing
  • add animated emote constraints
  • define mobile vs desktop sizing
  • spec future AVIF/WebP dual variants

Say which.

Last updated on