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:
-
Asset storage
- Files committed to
/public/assets/emojis/
- Files committed to
-
Registry
- Hard-coded
LOCAL_EMOTESarray
- Hard-coded
-
Authorization
- Inline
requireslogic
- Inline
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 filesNo 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.webpRules
- 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.webpThis 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
→ APPROVEDNever 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 rulesThat 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
│ └─ 3xThe 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.webpPublic 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.webpWhy this is correct
- cacheable forever
- CDN-friendly
- trivial to migrate to S3/R2 later
- human-inspectable
- no runtime resizing
3️⃣ Database schema (this matters)
Option A (recommended): variants table
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:
-
Accept one high-quality source (e.g. 112×112)
-
Generate:
- 1x (28×28)
- 2x (56×56)
- 3x (84×84)
-
Store all three
-
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.webpYou 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.