Index: src/routes/account.js
===================================================================
--- src/routes/account.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ src/routes/account.js	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
@@ -16,5 +16,4 @@
 import path from 'path';
 import fs from 'fs';
-import { fileURLToPath } from 'url';
 import bcrypt from 'bcryptjs';
 import multer from 'multer';
@@ -28,9 +27,7 @@
 import { toWebp } from '../services/ImageWebpService.js';
 import { SUPPORTED } from '../services/i18n.js';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const AVATAR_DIR = path.resolve(
-  process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
-);
+import { mediaDir } from '../config/paths.js';
+
+const AVATAR_DIR = mediaDir('AVATAR_PATH', 'avatars');
 fs.mkdirSync(AVATAR_DIR, { recursive: true });
 
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ src/routes/activitypub.js	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
@@ -23,6 +23,6 @@
 import path from 'path';
 import fs from 'fs';
-import { fileURLToPath } from 'url';
 import { randomUUID } from 'crypto';
+import { mediaDir } from '../config/paths.js';
 
 const router = express.Router();
@@ -422,8 +422,5 @@
 // AP convention) into the same store the reply editor uses, and gets back
 // { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
-const AP_MEDIA_DIR = path.resolve(
-  process.env.REPLY_MEDIA_PATH ||
-  path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'storage', 'media', 'reply-media')
-);
+const AP_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
 fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
 const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ src/routes/admin-audio.js	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
@@ -22,4 +22,5 @@
 import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
 import { audioUrl } from '../services/AudioStreamService.js';
+import { mediaDir } from '../config/paths.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -30,7 +31,5 @@
   process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
 );
-const COVER_DIR = path.resolve(
-  process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
-);
+const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
 fs.mkdirSync(AUDIO_DIR, { recursive: true });
 fs.mkdirSync(COVER_DIR, { recursive: true });
Index: src/routes/admin-media.js
===================================================================
--- src/routes/admin-media.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ src/routes/admin-media.js	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
@@ -10,14 +10,11 @@
 import path from 'path';
 import fs from 'fs';
-import { fileURLToPath } from 'url';
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
 import { requireGod } from '../middleware/auth.js';
 import { audioEnabled } from '../config/features.js';
+import { mediaDir } from '../config/paths.js';
 
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const POST_IMAGES_DIR = path.resolve(
-  process.env.POST_IMAGES_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'post-images')
-);
+const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
 
 const router = express.Router();
@@ -27,7 +24,5 @@
 // C2S uploads (Shaer's composer and the help buoy) land here; the videos among
 // them are what the Video tab shows.
-const REPLY_MEDIA_DIR = path.resolve(
-  process.env.REPLY_MEDIA_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'reply-media')
-);
+const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
 const isSibling = (f) => /-v\.(mp4|jpg)$/i.test(f); // an animated cover's video/poster sibling
 
Index: src/routes/admin-playlists.js
===================================================================
--- src/routes/admin-playlists.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ src/routes/admin-playlists.js	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
@@ -23,12 +23,10 @@
 import { requireGod } from '../middleware/auth.js';
 import PlaylistService from '../services/PlaylistService.js';
+import { mediaDir } from '../config/paths.js';
 
 // Cover storage — same convention as track covers so a single physical
 // directory holds all album/track artwork. Existing covers in the DB
 // already point at /media/audio-covers/<filename> so we reuse the path.
-const __dirname = path.dirname(new URL(import.meta.url).pathname);
-const COVER_DIR = path.resolve(
-  process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
-);
+const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
 fs.mkdirSync(COVER_DIR, { recursive: true });
 
Index: src/routes/admin-settings.js
===================================================================
--- src/routes/admin-settings.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ src/routes/admin-settings.js	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
@@ -16,5 +16,4 @@
 import path from 'path';
 import fs from 'fs';
-import { fileURLToPath } from 'url';
 import multer from 'multer';
 import { v4 as uuid } from 'uuid';
@@ -26,4 +25,5 @@
 import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
 import { toWebp } from '../services/ImageWebpService.js';
+import { mediaDir } from '../config/paths.js';
 
 const router = express.Router();
@@ -36,10 +36,7 @@
 }
 
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
 // Hero uploads land in storage/media/hero → accessible as /media/hero/<file>
 // (the /media static handler serves storage/media). Same model as avatars.
-const HERO_DIR = path.resolve(
-  process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero')
-);
+const HERO_DIR = mediaDir('HERO_PATH', 'hero');
 fs.mkdirSync(HERO_DIR, { recursive: true });
 
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ src/routes/admin-sites.js	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
@@ -17,5 +17,4 @@
 import path from 'path';
 import fs from 'fs';
-import { fileURLToPath } from 'url';
 import multer from 'multer';
 import { v4 as uuid } from 'uuid';
@@ -26,13 +25,11 @@
 import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
 import { toWebp } from '../services/ImageWebpService.js';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
+import { mediaDir } from '../config/paths.js';
+
 
 // Profile photos share the avatar directory with user avatars — same physical
 // folder, same URL prefix. Filenames are uuid-prefixed so site photos and
 // user avatars never collide.
-const PHOTO_DIR = path.resolve(
-  process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
-);
+const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
 fs.mkdirSync(PHOTO_DIR, { recursive: true });
 
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 2a17f0a714ea476a1c6090cbbad470e9a2d46104)
+++ src/routes/posts.js	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
@@ -3,5 +3,4 @@
 import path from 'path';
 import fs from 'fs';
-import { fileURLToPath } from 'url';
 import multer from 'multer';
 import ejs from 'ejs';
@@ -25,10 +24,7 @@
 import { verifyBlob } from '../services/CryptoBox.js';
 import MusicMeta from '../services/MusicMeta.js';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const POST_IMAGES_DIR = path.resolve(
-  process.env.POST_IMAGES_PATH ||
-  path.join(__dirname, '..', '..', 'storage', 'media', 'post-images')
-);
+import { mediaDir } from '../config/paths.js';
+
+const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
 fs.mkdirSync(POST_IMAGES_DIR, { recursive: true });
 
@@ -38,8 +34,5 @@
 // Rich replies: media dropped/pasted into the reply editor. Images, audio and
 // video, stored as-is (no transcode; a reply attachment is not a track).
-const REPLY_MEDIA_DIR = path.resolve(
-  process.env.REPLY_MEDIA_PATH ||
-  path.join(__dirname, '..', '..', 'storage', 'media', 'reply-media')
-);
+const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
 fs.mkdirSync(REPLY_MEDIA_DIR, { recursive: true });
 const ALLOWED_REPLY_MEDIA_EXT = new Set([
