Building a Small Automation with Supabase, Cron, and Edge Functions
I built a simple Bluesky publishing automation using Supabase, Cron, Edge Functions, Vault, and PostgreSQL. Here's what a tiny side project taught me about reliability, security, AI-assisted development, and production-ready automation.
I recently wanted to solve a very small problem. I had started using Bluesky mostly to share links to my blog posts, but I wanted the account to feel a little more active and personal. I had a growing list of short thoughts about software development, architecture, AI, working with teams, and the occasional developer joke, but I didn't particularly want to remember to open Bluesky every morning and manually publish one.
There are plenty of tools that can schedule social media posts, but this felt like a small enough problem that adding another subscription seemed unnecessary. So I started wondering how much of it I could build with infrastructure I was already using.
The initial idea was almost embarrassingly simple:
Content pool
↓
Supabase
↓
Cron
↓
Edge Function
↓
Bluesky APIStore a list of posts in a database, pick one once a day, send it to Bluesky, mark it as published. That was basically it, and technically, it would have worked. But as is usually the case with software, the interesting part started once I stopped thinking about the happy path.
A Table and a Scheduled Function
I already use Supabase on another project, so I decided to use it for the experiment rather than introduce another backend. The first piece was a small social_posts table. Each post contains the content, a category, timestamps, and some metadata returned by Bluesky after publication. More importantly, every post has a status, and the lifecycle eventually became:
draft → ready → publishing → published
↓
failedAt first, I considered just using ready and published — the scheduled function would select a ready post, publish it, and mark it published. Simple... The problem is that there's a small window between selecting the post and updating its status: if two executions happen at the same time, they could both select the same row and publish the same content twice. For something that runs once a day, that might sound unlikely, but I've learned that "unlikely" is usually a bad reliability strategy.
So instead of selecting a post and updating it separately, the system reserves it first. A small PostgreSQL function uses FOR UPDATE SKIP LOCKED to atomically select one ready post and move it to publishing in a single statement. Once a post is reserved, no other execution can pick it up, and only then does the Edge Function call Bluesky. That's probably more engineering than a social media scheduler strictly needs, but it also made the whole workflow much easier to reason about.
The First Failure Happened Immediately
The first manual test failed. The function reserved the post correctly, attempted to authenticate with Bluesky, and got back a 400 — so that post moved to failed while the remaining posts stayed untouched. It turned out to be an authentication configuration issue; once I fixed it, I ran the test again, and this time Bluesky returned a URI and CID, and the corresponding row moved to published.
The result was exactly what I wanted:
Post 1 → failed
Post 2 → published
Post 3 → readyAfter fixing the original authentication problem, I could safely move the first failed post back into the ready pool, because I knew it had never actually reached Bluesky. It was a tiny failure in a tiny system, but it was also a useful validation of the design — the failure state wasn't theoretical anymore. It had already been useful before the automation even went live.
Secrets Became More Interesting Than Expected
Another part I didn't want to take lightly was authentication. The Edge Function needed credentials for Bluesky, and the scheduled job needed permission to invoke that function. I didn't want to reuse a broad service credential unnecessarily, especially since this automation was temporarily living inside the same Supabase project as an unrelated application.
So I created a dedicated secret API key specifically for the Bluesky automation. The Edge Function only accepts that named secret, and the Cron job retrieves the key from Supabase Vault at runtime and sends it through the apikey header when invoking the function — the raw credential never needs to live in the migration that creates the scheduled job:
Supabase Cron
↓
Vault
↓
Dedicated automation API key
↓
Edge Function
↓
BlueskyThis feels slightly excessive for a one-post-per-day automation, but I also know how these things evolve: the temporary script becomes permanent, the experimental integration stays around for three years, and someone eventually forgets why a credential has access to everything. Creating the boundary while the system is still small is usually cheaper than trying to add it later.
I Didn't Let AI Publish Anything It Invented
There was another decision I made early: I didn't want an AI model generating posts and immediately publishing them under my name. AI helped organize my initial content pool, but the workflow deliberately separates content generation from publication:
Idea
↓
AI-assisted draft
↓
My review
↓
ready
↓
Automated publishingThe publishing system itself is intentionally boring — it doesn't decide what I think, doesn't generate opinions, and doesn't invent experiences. It only publishes content that's already been reviewed and approved by me, and only me. I like that separation because it reflects how I use AI in development generally: I'm comfortable letting it help me explore options, generate drafts, inspect systems, or accelerate implementation, but much less interested in letting it silently make decisions I ultimately have to own.
Cursor Was Part of the Workflow Too
I also built most of this through Cursor connected to Supabase. The interesting part of the workflow was that I wasn't manually writing every migration or Edge Function from scratch; I wrote a spec document describing what I wanted, Cursor inspected the existing Supabase project, proposed a plan, and we iterated on it together:
Describe the system
↓
Cursor inspects Supabase
↓
Propose implementation
↓
Review security and failure cases
↓
Apply migration
↓
Deploy Edge Function
↓
Run controlled test
↓
Enable CronThere were several moments where the first proposal changed: we added the publishing state to prevent duplicate execution, changed the service-to-service authentication approach, hardened the PostgreSQL function's search_path, and tested the Edge Function manually before enabling the scheduled job. None of those decisions were particularly complicated, but they're exactly the kind of decisions that disappear when people describe AI-assisted development as simply "asking a model to build the feature." The code generation wasn't the interesting part — deciding what the system should do when things go wrong was.
Small Automations Are Still Production Systems
What surprised me most about this experiment was how quickly a trivial automation started raising familiar production questions:
- What happens if the job runs twice?
- What happens if the external API is down?
- What happens if authentication fails?
- What happens if the external API succeeds but the database update fails?
- How do you know whether something was already published?
- Where should secrets live, and how much access should the automation have?
- How do you retry safely?
None of these questions are specific to Bluesky. The same pattern shows up in scheduled reports, cleanup jobs, health checks, notifications, data synchronization, and dozens of other small internal tools. The architecture is often remarkably similar — database, scheduled trigger, function, external system — and the happy path is usually easy. Reliable automation starts with everything around it.
A Lot Can Be Done Without Another Service
One thing I enjoyed about this experiment was realizing how much I could do with infrastructure I already had. The same basic Supabase setup could support things like weekly reports (database → Cron → Edge Function → email), health checks (Cron → function → endpoint → store result), data cleanup (Cron → function → database), external synchronization (Cron → function → third-party API), or content publishing (database → Cron → function → social API).
I'm not suggesting every automation should be custom-built — there are plenty of situations where paying for Zapier, Make, Buffer, or another specialized tool is absolutely the right trade-off. But for small engineering-driven workflows, I've started asking a different question before reaching for another SaaS product: can the infrastructure I already have solve this well enough? Sometimes the answer is no. In this case, it was yes.
The Smallest Systems Still Deserve Clear Boundaries
The automation now runs once a day. It takes one approved post, reserves it, publishes it, stores the result, and leaves the rest of the pool alone. It's not a complicated system, and that's probably why I enjoyed building it — small projects make engineering decisions easier to see. There isn't enough complexity to hide behind, so you can clearly tell the difference between something that works and something you'd actually trust to run unattended.
I started with a simple goal: stop manually posting to Bluesky every morning. I ended up with another reminder of something I've seen repeatedly in larger systems — automation is usually easy. Making automation boring enough that you don't have to think about it anymore is where the engineering starts.