Skip to content
Development

The syndication tracker that couldn't answer its own question

By Victor Da Luz
railsrubyinfrastructuredev-logblog-manager

I built blog-manager for one main reason: I cross-post vdaluz.com articles to Medium and Dev.to, and I wanted one place that knows what’s already where. This week I sat down to actually use it for that and realized I couldn’t. The data was all there in the database. The UI just refused to tell me.

The invisible absence problem

The posts index had a syndication column with little status pills: Medium, Dev.to, Social. My original design decision was to only render a pill when something had happened on that target, so a post that was never syndicated stayed “quiet” with a muted dash. It felt clean at the time.

In practice it inverted the signal. My actual question is “which posts are NOT on Medium yet?” and the answer was encoded as the absence of a pill, which you can’t scan for across 86 rows. Worse, a row showing only a Social pill was ambiguous: is Medium missing, pending, or never attempted? The exact state lived in the pill color plus a hover tooltip. Colorblind users, touch users, and me at 11pm all lose.

The fix was embarrassingly small. Always render all three pills, and add an explicit “not published” state with a hollow dot:

STATES = {
  done:    { css: "status-set",     label: "done" },
  pending: { css: "status-soon",    label: "in progress" },
  failed:  { css: "status-expired", label: "failed" },
  none:    { css: "status-none",    label: "not published" }
}.freeze

Plus filters. The index already had a ?filter=missing_hero pattern, so I added needs_medium, needs_devto, and failed. One wrinkle: per-target state lives in a serialized JSON column (postiz_publish), so these filter in Ruby with @posts.select(&:needs_medium?) instead of SQL. At 86 unpaginated posts that’s the right trade; I left a comment saying so for future me who will inevitably try to “fix” it.

Dead ends in the publish flow

Publishing an article to Medium goes through Postiz as a draft, then a human (me) clicks publish in the Postiz UI. The app showed staged drafts as muted text reading “Awaiting publish in Postiz” with the explanation in a tooltip. It told you where the next step was and gave you no way to get there. Now it’s a link to the Postiz launches calendar, derived from the API base URL already in settings:

def postiz_web_url
  postiz_base_url.sub(%r{/api/public/v\d+/?\z}, "")
end

Same treatment for unmet prerequisites. Dev.to publishing needs a live URL, a hero image, and a GitHub token, and the disabled Publish button explained that only on hover. It now says what’s missing inline: “needs a header image and a GitHub token.”

I also extended sharing so social blurbs can link the Medium or Dev.to copy instead of only the canonical URL. The plumbing was already threaded through (share_url(source) existed but ignored its argument), so the whole feature was making shareable_urls return the per-provider release_urls when they exist. The picker UI appeared on its own; it was already coded to show when there’s more than one source.

What the review caught

The review pass earned its keep this time. The finding that mattered: a system test still asserted the old “Awaiting publish in Postiz” text. I never noticed because bin/rails test doesn’t run system tests, and the CI system-test job is continue-on-error. It would have merged silently red and stayed broken on main. The review also pushed me to make the controller guards call the same needs_medium? predicate the filters use, instead of keeping a hand-rolled copy of the condition that would drift.

Then the build failed, twice, and it wasn’t my code

All the code checks passed and the Docker build job died resolving docker.io/docker/dockerfile:1. That’s the BuildKit frontend image pulled by the # syntax= directive on line 1 of the Rails-generated Dockerfile. I’d already moved the base image to a registry mirror because Docker Hub rate-limits the homelab’s outbound IP, but the syntax directive was a second, sneakier Docker Hub dependency. Deleted it; the built-in frontend covers everything a boring Dockerfile uses.

The rerun then timed out against the mirror too, which meant it was never rate limiting. The self-hosted runner’s containers had no outbound connectivity at all. Host fine, containers dead, NAT rules present, FORWARD policy ACCEPT. The tell was in the counters: packets entered the DOCKER-USER chain and never came out. A parallel homelab change had added firewall rules there to restrict inbound access to the app’s proxy ports, matching on destination port only. DOCKER-USER sees forwarded traffic in both directions, so those rules also dropped every outbound container connection to ports 80 and 443: registry pulls, and, uncomfortably, production’s calls to Postiz, GitHub, and Pexels. The rules had been saved with netfilter-persistent and sat dormant until both containers rebooted that evening. The fix was scoping them with -i eth0 so they only match traffic arriving from the LAN.

Two takeaways I’m keeping. First, “CI is red” and “your diff is wrong” are different claims; the code jobs were green the whole time. Second, iptables rule counters (iptables -Z, run traffic, read -L -v -n) found in five minutes what log reading couldn’t: the packets told me exactly which rule ate them.

What’s next

Dev.to is still empty, so the backfill starts now: 78 posts, one Publish click at a time. If that gets old fast I have a capped bulk-staging design sketched. The review also left a follow-up list I deliberately didn’t gold-plate into this PR: a single syndication_state(provider) source of truth instead of four scattered state parses, and a stat-card component for the dashboard’s copy-pasted markup.

Related reading

Development

The editor commit button is a deploy button

Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.

Read