Sproutly: Automated Instagram Posting for Photographers


I shoot a lot of wildlife and nature photography. I have thousands of photos I'm proud of sitting on a NAS doing nothing. I have an Instagram account (@geoffexplores) that I post to maybe once every few months because I hate the process of creating posts — picking a photo, writing a caption, coming up with hashtags, actually hitting publish. The friction is enough that I just don't do it.

The photos are already edited. They're already exported. The bottleneck is entirely the posting workflow. So I automated it.

Sproutly picks a random unposted photo from a folder on my Mac, sends it to Gemini for caption and hashtag generation, stages it on a DigitalOcean droplet, posts it to Instagram via the Graph API, and cleans up after itself. It runs daily at 4pm via a macOS LaunchAgent. I don't touch it.


The Folder Structure

The design constraint I started with: the tool has to fit how I actually work, not the other way around. After I finish editing a shoot in Lightroom, I export selects to a shoot folder. I don't want to run an import command or register photos in a database. I just want to drop them somewhere and have the tool find them.

The structure is deliberately simple:

~/Documents/Instagram/
  2026-01-28-Maasai-Mara/
    20260128-DSC01558.jpg
    20260128-DSC01602.jpg
    README.md
  2025-Eurotrip/
    Scotland/
      20250520-DSC09266.jpg
      README.md
    France/
      20250522-DSC09841.jpg
      README.md

Each folder gets a README.md with context about the shoot — where I was, what I was shooting, relevant gear notes. The tool reads this and passes it to the AI alongside the image and EXIF data. Subdirectories work automatically — the scanner recurses the full tree on every run.

The README is just plain text. An example from the Maasai Mara folder:

I went on a safari in Maasai Mara, Kenya in January 2026.
Shot on Sony A7RV with Sigma 300-600mm f/4.
Golden hour light just after sunrise — the Mara in peak dry season.

The more context you put in, the better the captions. The tool works without a README, but the output is noticeably less specific.


The Stack

  • Python 3.13 — runs locally on the Mac via LaunchAgent
  • Gemini 2.5 Flash — vision model for caption and hashtag generation
  • Instagram Graph APIgraph.instagram.com/v21.0 for publishing
  • DigitalOcean droplet — temporary image staging via nginx
  • SQLite — tracks posted images, prevents duplicates
  • exifread — extracts camera, lens, focal length, aperture, ISO from image metadata

The project is six Python modules:

ModuleResponsibility
main.pyOrchestration — scan, pick, generate, upload, post, record
db.pySQLite wrapper — track attempts, successes, failures
vision.pyGemini API call — image + README + EXIF → caption + hashtags
instagram.pyGraph API — two-step media container creation and publish
uploader.pySCP image to droplet, return public URL, delete after posting
exif.pyExtract and format EXIF data for the AI prompt

The Instagram API

Instagram's publishing API is two HTTP calls. First, create a media container with the image URL and caption:

POST https://graph.instagram.com/v21.0/{account_id}/media
  ?image_url={public_url}
  &caption={caption + hashtags}
  &access_token={token}

Then publish it:

POST https://graph.instagram.com/v21.0/{account_id}/media_publish
  ?creation_id={container_id}
  &access_token={token}

That's the whole posting integration. The complexity is elsewhere.

The image must be at a publicly accessible HTTPS URL when the container is created — Instagram fetches it server-side. You can't POST raw bytes. This is why the staging server exists: the tool SCPs the image to a DigitalOcean droplet, Instagram fetches it, then the tool deletes it. The image is live on the staging server for maybe ten seconds.

The access token is an IGQ-prefixed Instagram token, not the Facebook Graph token I expected from the documentation. Meta has overhauled their developer portal significantly — a lot of the existing tutorials and documentation describe a flow that no longer exists. The current flow goes through graph.instagram.com directly, not graph.facebook.com. I'd recommend ignoring anything older than late 2025 on this topic.

The token expires every 60 days. Sproutly refreshes it automatically on every run and writes the new token back to .env.


The Staging Server

The simplest possible setup: an nginx container on an existing DigitalOcean droplet, serving a single directory, behind a Cloudflare-proxied subdomain. The subdomain name is deliberately opaque — advertising the purpose of an endpoint is unnecessary.

docker run -d \
  --name nginx-uploads \
  --restart unless-stopped \
  -p 8006:80 \
  -v /opt/sproutly/upload-staging:/usr/share/nginx/html/ig:ro \
  nginx:alpine

One HAProxy ACL addition and one Cloudflare DNS record. Cloudflare handles HTTPS. No certs to manage.

The tool uploads with a UUID filename, posts, then SSH-deletes the file. Nothing accumulates on the server.


Caption Generation

The prompt is the part that took the most iteration. The first version produced 180-word travel blog captions full of phrases like "these are the moments I live for" and "patience rewarded." Everything could have been written about any wildlife photo by anyone.

The fix was specificity in the prompt constraints:

  • 60–100 words maximum — the word count ceiling was the primary driver of padding
  • "Like a photographer talking to other photographers" — gives the model a specific audience instead of defaulting to travel influencer voice
  • An explicit filler blacklist — naming the exact phrases to avoid is more effective than just saying "authentic"
  • Conditional gear mention — only include camera/lens if it genuinely adds context, skip it if it feels forced
  • Structured hashtag guidance — 2 species/location specific, 2 mid-tier, 2 broad

The model also gets the EXIF data — camera body, lens, focal length, aperture, shutter speed, ISO. For wildlife photography this is genuinely useful context: a 600mm shot at 1/2000s tells a different story than a 24mm shot at f/11.

The response is requested as JSON:

{
  "caption": "caption text here",
  "hashtags": ["#tag1", "#tag2", "#tag3", "#tag4", "#tag5", "#tag6"]
}

Gemini occasionally wraps this in markdown code fences. The parser strips them before decoding.


Duplicate Prevention

SQLite with a UNIQUE constraint on the filepath column. Every image that's attempted gets a row. Successfully posted images have status = 'posted' and are permanently excluded from the candidate pool. Failed attempts have status = 'failed' and are also excluded — to retry a specific image, delete its row.

CREATE TABLE posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    filepath TEXT UNIQUE NOT NULL,
    folder TEXT NOT NULL,
    filename TEXT NOT NULL,
    instagram_post_id TEXT,
    caption TEXT,
    hashtags TEXT,
    status TEXT DEFAULT 'pending',
    error_message TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    posted_at TIMESTAMP
);

The DB lives alongside the photos in the Instagram folder rather than in the project directory. This means it survives repo changes and is backed up with the photos.


The Scheduler: cron to LaunchAgent

The first attempt at scheduling used macOS cron. It worked, but had two problems: cron silently skips jobs if the machine is asleep at the scheduled time and doesn't catch up on wake, and the first run triggered a macOS permissions dialog for Documents folder access that sat unacknowledged until I happened to unlock the screen.

The fix was migrating to a macOS LaunchAgent with pmset to wake the machine before the job fires. The setup is a com.sproutly.plist agent definition and a launchd_setup.sh install script. On install, the script also runs:

sudo pmset repeat wake MTWRFSU 15:55:00

This wakes the Mac at 15:55 every day, five minutes before the 16:00 LaunchAgent fires. If the machine is already awake, pmset does nothing. If it was asleep, it wakes it in time for the job. The LaunchAgent logs to ~/Library/Logs/sproutly/.

Useful commands once it's installed:

./launchd_setup.sh status   # check agent + recent logs
./launchd_setup.sh logs     # tail live logs
launchctl start com.sproutly  # trigger a manual run

What Went Wrong

The Meta developer portal is a different product than the documentation describes. Most of what exists online about the Instagram Graph API describes a Facebook-centric flow through graph.facebook.com that involved creating Business-type apps with specific product configurations. The current portal has a different app creation flow, a different token type, and routes through graph.instagram.com. I spent more time here than anywhere else. The API calls themselves are simple — finding the right path to a working token is not.

The Google Generative AI Python package is deprecated. The package is google-genai now, not google-generativeai. The old package still installs and imports, but every call prints a deprecation warning and the models have diverged — gemini-1.5-flash returns a 404, gemini-2.0-flash is unavailable to new users, gemini-2.5-flash works. The client API also changed: it's genai.Client(api_key=key) and client.models.generate_content() now, not the old genai.configure() / GenerativeModel() pattern.

503s from Gemini aren't failures, they're noise. Gemini 2.5 Flash occasionally returns 503 during high demand. The tool treats this as a fatal error and records a failed attempt in the DB, which then requires manual cleanup before the image will be retried. A retry loop with exponential backoff would handle this correctly. It's not implemented yet.

macOS sandboxing prompts on first cron run. The very first execution triggered a permissions dialog asking whether Python should be allowed to access the Documents folder. Because this happened while I wasn't at the machine, the dialog sat there until I unlocked the screen — blocking the job silently. Once approved it's permanent, but it means the first run needs someone present. The LaunchAgent migration didn't fully eliminate this, but the pmset wake approach means the machine is at least in a known state before the job fires.


What's Left

  • Retry logic. Transient Gemini 503s shouldn't mark an image as failed. A retry loop with backoff before giving up would fix this without manual DB cleanup.
  • Caption review mode. An optional flag to generate the caption and print it for review without posting — useful when you want to tweak the output before it goes live.
  • Image tagging. Tagging people in the image itself (not just mentioning them in the caption) requires a separate API call with the tagged user's Instagram user ID. The README supports mention syntax for caption tags; image-level tagging is a future addition.

The Result

It works. Posts going out daily without me touching anything after initial setup. The caption quality is noticeably better than what I'd write if I had to do it manually at 4pm on a Tuesday — because I'd never actually do it at 4pm on a Tuesday.

The whole thing is about 400 lines of Python across six files. The infrastructure is one nginx container on a droplet I already had, one subdomain, one LaunchAgent. The ongoing cost is a few cents a month in Gemini API calls.