Skip to content
Development

Scheduling and smarter link-sharing for my Postiz cross-poster

By Victor Da Luz
railsrubypostizdev-logblog-manager

A while back I wired my blog manager (a small Rails 8 app) into Postiz, a self-hosted social scheduler, so I could fire a post out to Bluesky, Mastodon, LinkedIn and the rest from one button. It worked, but it was blunt: it posted immediately, and the blurb was just the title plus the canonical URL. No image, no description, no way to say “actually, send this one tomorrow morning.” This is the log of fixing that, including two bugs that only showed up once I was clicking around the real UI.

What I was trying to do

Three things:

  • Schedule a post for a future time instead of always posting now.
  • Make the blurb richer: include the post description and attach a thumbnail image.
  • Let me pick which URL gets shared, since most posts also live on Medium and Dev.to.

Reading the API before writing any code

The Postiz public API isn’t huge, but I didn’t want to guess at request shapes. I pinned my reading to the exact version my instance runs (v2.21.8) rather than whatever main looks like today, and that paid off immediately: the first controller file I opened was the wrong one and had no upload route at all. The real public API controller had what I needed:

  • POST /upload-from-url takes a JSON { "url": "..." } and returns a media object with id and path. That’s a plain JSON body, so my existing HTTP client worked as-is, no multipart handling.
  • POST /posts attaches media per channel as value[].image: [{ id, path }].
  • type is now | schedule | draft, with date as the scheduled time.
  • GET /find-slot/:id returns the next free slot for a channel: { "date": "..." }.

Two minutes of reading the actual source saved me from building a multipart uploader I didn’t need.

What I built

Scheduling was the easy part once the payload was clear. The poster now takes an optional scheduled_at; if it’s in the future, it sends type: "schedule" with that date, otherwise it falls back to posting now. The picker got a datetime field and a “Suggest next free slot” button that calls find-slot for the channels I’ve checked and fills in the field.

For the thumbnail, I upload the post’s existing hero image (a Pexels picture I already store) through upload-from-url and attach the returned id to every channel. I made the upload best-effort on purpose: if it fails, the post still goes out as text rather than the whole thing dying because one image 500’d.

The description goes into the blurb too, but X and Bluesky are short, so I truncate the whole thing to about 280 characters, reserving room for the title and URL first and trimming the description to fit.

The timezone bug I almost shipped

Here’s the one I’m glad I caught before merging. The datetime picker in the browser hands you a “wall-clock” string with no timezone: 2026-06-10T09:00. My Rails app was running in UTC (the default). So if I picked 9:00 AM, the server happily parsed it as 9:00 UTC, which is 3:00 AM my time. The post would queue six hours off, and the worst part is the “Scheduled for” label would render in the same UTC, so it looked internally consistent. Nothing on screen would tell me it was wrong until the post fired at the wrong hour.

This is a single-user app that I run from one timezone, so the fix was a one-liner: set config.time_zone to my actual zone. Now the picker, the parsing, and the display all agree, and the database still stores UTC underneath. If this were multi-user I’d have to do the proper browser-offset dance, but it isn’t, so I didn’t.

The bug that made me say “didn’t work”

The link picker needed real data to be worth testing, so I wrote a small rake task to copy Medium URLs from my production database down to my local one, matched by blog and slug. Production is SQLite on a Docker volume, so “read from prod” really means “pull a consistent snapshot and read that.” I used VACUUM INTO to make the snapshot (no sqlite3 CLI needed in the container), scp’d it down, and ran the import. It reported 69 URLs copied.

Then I loaded the UI and… nothing. Every post still said “Not imported” for Medium.

I almost started guessing, but I made myself go read the view instead. The Medium section of the page keys off medium_status (an enum: not imported / draft / published), not off medium_url. I had copied the URL but left the status at “not imported,” so as far as the UI was concerned nothing had changed. The URL was technically there, just invisible.

The fix was to copy the syndication state, not just the URL: medium_url and medium_status together (plus a couple of related fields). I checked prod and, sure enough, all 69 of those posts were published. Re-ran the import, and the badges lit up. Obvious in hindsight, but a good reminder that “the data is in the column” and “the UI shows it” are two different claims.

What I’d do differently and what’s next

If I were doing the data copy again I’d think about what the UI actually reads before deciding which columns to copy, instead of fixating on the one field in the task name. The timezone thing I’d catch earlier by just asking “what timezone is this string in?” the moment a date crosses the browser/server boundary.

Next up is the webhook receiver that flips a post from “scheduled” to “posted” once Postiz actually publishes it, so the per-channel published URLs come back into the app. For now I can schedule, enrich, and choose my link, and the posts land where and when I want them.

Related reading

Development

Add a feature, or move a responsibility?

Adding Postiz social cross-posting looked done until a blunt question exposed a double-post bug, and a full audit of every posting path in the app found two more like it.

Read