Social Share: Multi-Platform Publisher
A write-once, publish-everywhere dashboard: one post becomes a separate BullMQ job for each social network, with retries, scheduling, a dead-letter queue and AES-256-GCM-encrypted credentials.
- Node.js
- Express
- TypeScript
- MongoDB
- Mongoose
- BullMQ
- Redis
- Zod
- OAuth 2.0 / PKCE
- AES-256-GCM
- pino
- Angular 19
- Angular Signals
- Docker
- nginx

Architecture

Overview
Social Share is a dashboard for publishing to many social networks at once. You write a post once and send it to many networks. Each destination succeeds or fails on its own, and you can retry or cancel each one.
Each network has its own service in a registry. The networks include Telegram, Discord, Bluesky, Pinterest, LinkedIn, Facebook, Instagram, Threads, YouTube and TikTok.
I built both sides:
- A Node.js, Express and TypeScript API, with a separate worker process.
- An Angular 19 front end that uses signals.
It is a working MVP with a production-style Docker Compose stack.
The challenge
- Every network has its own outages, rate limits, content rules and way of signing in.
- One post goes to many of them. A LinkedIn outage must not block Telegram, and a bad image must not use up retries.
- No destination may be published twice.
- The app holds other people's access tokens, bot tokens and app passwords. None of them may leak through the database, logs or error messages.
What I built
- Accounts: connect accounts through each network's OAuth 2.0 (with PKCE where it is required), a bot token or an app password.
- Composer: checks a post against rules the server publishes for each network: maximum length, media rules and title rules. The server is the only source of these rules.
- Scheduling: a scheduled post is a delayed job, so there is no cron.
- History: shows the status, link and error for each destination, and lets you retry or cancel each one.
- Worker: a separate process with exponential backoff, a dead-letter queue and an hourly clean-up job.
- Security: credentials are encrypted at rest, secrets are removed from logs and API errors, and a user's role is read from the database on each request.
Tech & architecture
- Backend: Node.js, Express 4, TypeScript, MongoDB 7 with Mongoose 8, BullMQ 5 on Redis 7, Zod, Helmet, Multer and pino.
- Frontend: Angular 19 with standalone components and signals, and no NgRx. It is served by nginx.
- Infrastructure: Docker Compose with five services (MongoDB, Redis, API, worker and web), each with a health check. There are two multi-stage Dockerfiles.
Architecture
- The Angular app calls the Express API through nginx, which also serves hashed assets with a 365-day immutable cache.
- Users connect accounts with OAuth 2.0, a bot token or an app password. Credentials are encrypted at rest with AES-256-GCM. The OAuth state can be used once and expires after 10 minutes.
- The composer loads each network's limits from the API and checks the post against them before sending.
- Creating a post writes one Post record and one record for each destination. Only then does it queue one BullMQ job per destination. A scheduled post is a job with a delay.
- The worker finds the network's service in the registry, refreshes tokens if needed and publishes.
- Errors that can be retried (429 and 5xx) back off and try again. Permanent errors fail at once. Jobs that run out of attempts are copied to a dead-letter queue, with secrets removed.
- The history screen polls every five seconds, but only while a destination is still queued or posting.
Hard problems
Separate, retry-safe delivery to unreliable APIs
Each destination is its own database row and its own job. The job is queued only after the row exists, so a fast worker can never run before the data is saved. The worker owns the status of each destination: queued, then posting, then posted, failed or cancelled. It does nothing if the destination is already posted or cancelled. Each network's errors say whether they can be retried, and that decides between backing off and stopping for good. Each manual retry gets its own job id, so de-duplication never swallows it.
// One job per destination; the worker owns the status (simplified)
async function runTarget(targetId: string, attempt: number, maxAttempts: number) {
const t = await Targets.get(targetId);
if (!t || t.status === 'posted' || t.status === 'cancelled') return; // safe to run twice
await Targets.set(t.id, { status: 'posting' });
try {
const res = await registry[t.platform].post(t.account, t.content, t.media);
await Targets.set(t.id, { status: 'posted', permalink: res.permalink });
} catch (err) {
const retryable = err instanceof PlatformError ? err.retryable : true;
const final = !retryable || attempt >= maxAttempts;
await Targets.set(t.id, { status: final ? 'failed' : 'queued', error: scrub(err) });
throw retryable ? err : new UnrecoverableError(scrub(err)); // back off, or stop
}
}
Keeping other people's credentials safe
Tokens and passwords are encrypted with AES-256-GCM, using a random 96-bit IV and an authentication tag. They are stored in a versioned format (v1:iv:tag:ciphertext) so the key can be rotated later. Secrets are also kept out of everything the app outputs:
- Models remove secret fields when they are turned into JSON.
- One scrub function cleans every logged error and every API error message.
- The logger hides anything that looks like a token.
// AES-256-GCM with a versioned envelope (simplified)
const ALG = 'aes-256-gcm';
export function seal(plain: string, key: Buffer): string {
const iv = randomBytes(12); // 96-bit random IV
const c = createCipheriv(ALG, key, iv);
const data = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
return ['v1', ...[iv, c.getAuthTag(), data].map(b => b.toString('base64url'))].join(':');
}
export function open(sealed: string, key: Buffer): string {
const [v, iv, tag, data] = sealed.split(':');
if (v !== 'v1' || !iv || !tag || !data) throw new Error('Unknown format');
const d = createDecipheriv(ALG, key, Buffer.from(iv, 'base64url'));
d.setAuthTag(Buffer.from(tag, 'base64url'));
return Buffer.concat([d.update(Buffer.from(data, 'base64url')), d.final()]).toString('utf8');
}
Results in numbers
- 20 API endpoints: 3 auth, 3 media, 13 social and 1 health check.
- 6 Mongoose models.
- 3 BullMQ queues: a publish queue (5 jobs at a time, capped at 20 jobs per second), a dead-letter queue and an hourly maintenance job.
- Completed jobs are kept for 7 days (at most 5,000), and failed jobs for 30 days.
- 5 Docker Compose services and 2 multi-stage Dockerfiles.
- 4 lazy-loaded frontend routes.
- About 5,100 lines of backend TypeScript in 52 files, and about 1,630 lines of frontend TypeScript in 13 files.
Outcome
A working MVP. You write once and publish to many networks, and each destination is tracked, retried and explained on its own. Credentials stay encrypted, and the stack is packaged as a Docker Compose deployment designed to sit behind an existing reverse proxy.
