I post the same founder cases on Instagram (in Portuguese) and on X and LinkedIn (in English) every day, at fixed times. Doing that by hand got old fast, so now most of it runs on its own. This post is a walk through the boring middle of that pipeline: where the carousel images live, how they get scheduled, and the small rules that keep it from falling apart. Everything here is TypeScript on Node. the shape of it There are three moving parts: a bot that writes and renders the carousels (PNG slides, one set in Portuguese, one in English) and drops them in a delivery folder a second bot that is the only thing allowed to write to my Cloudflare R2 bucket. it archives the files, schedules the posts in Postiz and cleans up routines, which are just cron schedules in my timezone that wake those bots up in the morning The "only one writer" rule is deliberate. I took R2 access away from the creative bot. It hands files over, the manager uploads them in the standard layout, and nobody else touches the bucket. With one writer there's exactly one place that decides what a key looks like, so the archive doesn't slowly turn into three slightly different naming schemes. a key layout you can read R2 is S3-compatible and has no real folders, so the key is the whole filing system. Mine looks like this: ///__/_NN.png Enter fullscreen mode Exit fullscreen mode plus a caption.txt and a notes.txt next to the slides. Network first, then date, then the posting slot and the case. When I want to see what went out on a given day I list one prefix and that's it. Building it in one place means no bot gets creative with the format: // keys.ts type Network = "insta" | "x"; export function slotPrefix(net: Network, day: string, slot: string, slug: string) { // day = "2026-11-12", slot = "08h00" const [yyyy, mm] = day.split("-"); return `${net}/${yyyy}/${mm}/${day}_${slot}_${slug}/`; } export function slideKey(prefix: string, slug: string, index: number) { return `${prefix}${slug}_${String(index).padStart(2, "0")}.png`; } Enter fullscreen mode Exit fullscreen mode uploading to R2 The AWS SDK v3 works against R2 if you point it at the account endpoint and use region: "auto": // r2.ts import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { readFile } from "node:fs/promises"; export const r2 = new S3Client({ region: "auto", endpoint: process.env.R2_ENDPOINT, // https://.r2.cloudflarestorage.com credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, }, }); const BUCKET = process.env.R2_BUCKET!; const PUBLIC_BASE = process.env.R2_PUBLIC_BASE!; // public r2.dev or custom domain export async function putFile(localPath: string, key: string) { const body = await readFile(localPath); const contentType = key.endsWith(".png") ? "image/png" : "text/plain; charset=utf-8"; await r2.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body, ContentType: contentType })); return `${PUBLIC_BASE}/${key}`; } Enter fullscreen mode Exit fullscreen mode Set ContentType explicitly. Without it some objects come back as application/octet-stream and anything fetching them later gets confused. The bucket is public on purpose. It's an archive of stuff that is going to be posted publicly anyway, and a public URL is what makes the next step easy. handing the images to Postiz Postiz can't take a raw file path or a random URL in a post. Media has to be in its own library first. Since the slides already sit on a public R2 URL, I use the upload-from-url endpoint instead of streaming the bytes again: // postiz.ts const API = "https://api.postiz.com/public/v1"; const headers = { Authorization: process.env.POSTIZ_API_KEY!, "Content-Type": "application/json", }; type Media = { id: string; path: string }; export async function importMedia(url: string): Promise { const res = await fetch(`${API}/upload-from-url`, { method: "POST", headers, body: JSON.stringify({ url }), }); if (!res.ok) throw new Error(`upload ${res.status}: ${await res.text()}`); const { id, path } = (await res.json()) as Media; return { id, path }; } Enter fullscreen mode Exit fullscreen mode One thing to plan around: the Postiz public API docs list a limit of 30 requests per hour. Every slide is a request, so a carousel eats into that quickly. Two things help. Import each slide once and reuse its path anywhere that slide is needed. And put several networks in a single create call, because the posts array takes one entry per channel: type Slot = { date: string; // UTC ISO insta: { caption: string; media: Media[] }; en: { text: string; media: Media[] }; }; export async function scheduleSlot(s: Slot) { const body = { type: "schedule", date: s.date, shortLink: false, tags: [], posts: [ { integration: { id: process.env.POSTIZ_IG_ID! }, value: [{ content: s.insta.caption, image: s.insta.media }], settings: { __type: "instagram-standalone", post_type: "post" }, }, { integration: { id: process.env.POSTIZ_X_ID! }, value: [{ content: s.en.text, image: s.en.media }], settings: { __type: "x", who_can_reply_post: "everyone" }, }, ], }; const res = await fetch(`${API}/posts`, { method: "POST", headers, body: JSON.stringify(body) }); if (!res.ok) throw new Error(`schedule ${res.status}: ${await res.text()}`); return res.json(); } Enter fullscreen mode Exit fullscreen mode LinkedIn goes in the same way with __type: "linkedin" and post_as_images_carousel: true. In my setup it goes out half an hour after X and Instagram, so it's its own call with a different date. times live in my timezone, the API lives in UTC My slots are things like 8h00, 10h00, 11h30 in São Paulo time. Postiz wants UTC. Brazil dropped daylight saving in 2019, so a fixed -03:00 offset is honest here and I don't need a timezone library: export function slotToUtc(day: string, slot: string) { const [h, m] = slot.split("h"); return new Date(`${day}T${h.padStart(2, "0")}:${m}:00-03:00`).toISOString(); } slotToUtc("2026-11-12", "08h00"); // "2026-11-12T11:00:00.000Z" Enter fullscreen mode Exit fullscreen mode If you live somewhere with DST, don't copy this. Use Intl or a library that knows the zone rules. don't double post The routines can fire twice (a retry, me running it by hand, whatever). Before creating anything I list what's already scheduled in that window and skip slots that are taken: export async function alreadyScheduled(startUtc: string, endUtc: string, integrationId: string) { const qs = new URLSearchParams({ startDate: startUtc, endDate: endUtc }); const res = await fetch(`${API}/posts?${qs}`, { headers }); if (!res.ok) throw new Error(`list ${res.status}`); // check the response shape on your instance, I match on either field const data = (await res.json()) as { posts: { integrationId?: string; integration?: { id: string } }[] }; return data.posts.some((p) => (p.integrationId ?? p.integration?.id) === integrationId); } Enter fullscreen mode Exit fullscreen mode It costs one request, which is a lot cheaper than explaining two identical carousels on the same morning. cleanup last The delivery folder only gets deleted after R2 has the files and Postiz has accepted the post. If anything in the middle throws, the files stay where they are and the next run picks them up again: import { rm } from "node:fs/promises"; for (const slot of slots) { if (await alreadyScheduled(slot.windowStart, slot.windowEnd, process.env.POSTIZ_X_ID!)) continue; const urls = await Promise.all(slot.files.map((f, i) => putFile(f, slideKey(slot.prefix, slot.slug, i + 1)))); const media = await Promise.all(urls.map(importMedia)); await scheduleSlot({ ...slot.payload, en: { ...slot.payload.en, media } }); await rm(slot.deliveryDir, { recursive: true, force: true }); } Enter fullscreen mode Exit fullscreen mode (simplified, the real one handles the Portuguese and English sets separately) what I'd tell past me pick one writer for shared storage and stick to it put the date and time slot in the key. future you will grep by day, not by case name read the platform settings for every channel. a setting that doesn't apply just gets dropped quietly, the post still says success schedule early in the day and leave room to review. I check the queue in Postiz and delete anything I don't like before it goes out None of this is clever, and that's kind of the point. The interesting part of the content is the cases. The plumbing just has to not break at 8 in the morning. sources Postiz public API: create post Postiz public API: upload from URL Postiz public API: list posts Cloudflare R2: S3 API compatibility Cloudflare R2: public buckets I post about indie apps, micro-SaaS and building with AI on X and Instagram.
How I archive and schedule my daily carousels with R2 and Postiz (TypeScript)
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.