← All posts

Instagram Pi Publisher: a Raspberry Pi that runs my Instagram accounts

·6 min readPortfolio
A folder of photos on a web host, a Raspberry Pi holding the queue for feed posts, stories and reels, and a phone showing the published post

Posting to Instagram consistently is a chore that loses to real life. I’d take a batch of photos, pick a few, and then not post them for three weeks. So I built the thing I actually wanted: I drop photos into a folder on my website, and a Raspberry Pi sitting in the corner of the room posts them for me, on a schedule, through the official Meta Graph API.

It has been running two accounts unattended for months: @cwviews and @saidorneversaid. This is what it does and what turned out to be hard.

How it works

The whole pipeline is two scheduled jobs sharing one SQLite database.

Discovery runs every fifteen minutes. It connects over SFTP to a private folder on my web host, finds files it hasn’t seen before, and for each one: downloads it, converts it to a high-quality JPEG, reads the EXIF, checks it isn’t a duplicate, uploads the converted copy to a public folder, verifies that the public URL actually fetches, and marks it ready to publish.

Publishing runs at the times I’ve configured and takes exactly one post per firing. It builds the caption, calls the Graph API, and records what went out.

Everything in between — what’s queued, what failed and why, what’s already been published — lives in the database, so the two jobs never need to talk to each other.

What it can do

  • Three kinds of post, scheduled independently. Feed posts, stories and reels each have their own times, their own daily cap, their own systemd timer and their own lock. A story is not a small feed post: it’s ephemeral, you want several a day, and you want them at different times from your feed.
  • Carousels from a folder. Put several images in a subfolder named grp_holiday and they publish as one multi-image post, in filename order, once every image in the folder is ready. A carousel counts as one post against the daily budget, not as five.
  • Captions built from the photo. The date and time it was taken, where it was taken, and what it was shot on, read out of the EXIF and formatted into the caption, with my own intro and hashtags around it.
  • Soundtracks on reels, chosen least-recently-used from a pool fetched from Meta’s audio API, so the same track doesn’t come round twice in a week.
  • A preview mode. publish --dry-run renders exactly what would go out — the caption, the image URLs, the audio track it picked — and writes nothing at all. An Instagram post can’t be un-published, so being able to look before posting matters more here than anywhere else I’ve worked.
  • Deleting a post that’s already gone out, which the API does allow, taking the whole carousel with it, guarded behind typing out the post’s id.
  • A status dashboard. The Pi snapshots its own state and uploads it to a page on my web host: what’s queued per kind, the last few runs and whether they succeeded, the next firings, and when the access token expires. GPS coordinates and private URLs are stripped before it leaves the Pi, because that page is public.
  • Several accounts on one machine, each with its own config, database, timers and dashboard, from one checkout of the code.
  • Importing from another generator. A separate script of mine renders quote images and writes a manifest describing them. The publisher reads that manifest and feeds those images into the same pipeline, using the manifest’s own text as the caption. Re-running the generator updates the captions in place without re-uploading anything.

The hard part: you cannot un-publish

Every interesting decision in this project follows from one fact. The Graph API publishes immediately, and nothing can take a post back except deleting it — by which point people have seen it. So double-posting is the failure mode the whole design guards against.

Two independent layers stop it. Each post kind has its own lock file, and a run that finds the lock held exits rather than waits — a queued second run would just post again a moment later, which is precisely the thing being prevented. Underneath that, claiming a row for publishing is a single conditional UPDATE: the row moves to publishing only if it’s still queued, so even two processes racing past the lock can’t both win the same photo. Both layers exist because the discovery job can trigger a publish at the same moment a scheduled run starts.

The same reasoning shows up in smaller places. A run that dies mid-publish leaves its row marked publish_interrupted, and that status is never retried automatically, because the dead run may well have succeeded before it died. Picking a soundtrack never consumes one: the track is marked used only after the post lands. And a reel with no audio available publishes silent rather than failing, because losing the slot is worse than losing the music.

The other hard parts

Meta fetches the image itself, and caches by URL. You don’t upload a file to the Graph API — you hand it a public HTTPS URL and its servers come and get it. Which means if you re-render an image and overwrite it at the same filename, Meta happily publishes the copy it fetched the first time, successfully, and unfixably once it’s live. Every URL is now stamped with a hash of the file’s contents, so changed bytes mean a new URL and unchanged bytes keep a stable one — a retry mustn’t force a re-fetch.

A video has no EXIF. Photos were easy. A .mov from an iPhone keeps its date, location and device in a QuickTime moov box, which in a camera-original file sits at the end — so reading the first few kilobytes finds nothing. I wrote a small box parser that seeks over the top-level atoms to find it, then reshapes what it finds into the same fields the photo path produces, so one caption builder serves both.

The 60-day token. An Instagram access token lasts 60 days and can only be renewed while it’s still valid. Miss the window and the account is dead until someone re-authorises by hand. The publisher now checks the expiry at the start of every job and renews the token when ten days remain, rewriting it into the config file atomically, so an interrupted run can never leave half a credential on disk. Ten days of slack means a failed renewal costs a retry instead of an outage.

Scheduling had to be local. There’s no “post this at 11am” in the API, so all of it — the slots, the daily budgets, the catch-up when the Pi was switched off, the random jitter so posts don’t land on the exact same minute every day — is logic on the Pi, driven by systemd timers the tool generates and installs itself.

Keeping it operable

It’s unattended software on hardware in my house, so most of the effort went into being able to tell what it did.

Every job wraps itself in start/success/failure markers in the log, and cron verify reads those back to tell “ran and failed” apart from “never ran at all” — a distinction that’s invisible if you only check whether anything was posted. The tool generates its own systemd units from the config rather than shipping fixed copies, and a deploy disables the timers the config no longer wants, because installing what you want isn’t enough: a timer from last month’s setup keeps firing, and for a publish timer, keeps posting.

Around 970 automated tests cover the parts where a silent mistake means a wrong post: the scheduling maths, the per-kind budgets, carousel assembly, duplicate detection, caption building, the QuickTime parser, the token lifecycle. They’re pure and offline — no SFTP, no Graph API, no real database — and the whole suite runs in about seven seconds.

Built with

Python, SQLite, Pillow, paramiko, the Meta Graph API and systemd, on a Raspberry Pi. Linted with ruff, type-checked with mypy, tested on Python 3.9, 3.11 and 3.12 in CI.

If you’re thinking about building something similar, or you’ve fought the Graph API yourself, I’d like to hear about it: get in touch.