Prerequisites
- Ruby 3.4+
- libvips — for image processing (resize before upload)
- SQLite3 — for Solid Queue job storage and PublishLog
# macOS
brew install ruby libvips sqlite3
# Ubuntu/Debian
sudo apt install ruby-full libvips-dev libsqlite3-dev
Installation
git clone https://github.com/fberrez/blurt.sh.git
cd blurt.sh
bundle install
cp .env.example .env
bin/rails db:prepare
Configuration
Edit .env with your credentials. You only need to configure the platforms you want to use.
API key
Required for the HTTP API. Generate a random secret:
openssl rand -hex 32
BLURT_API_KEY=your-generated-secret
Bluesky
Uses an app password (not your main password).
BLUESKY_SERVICE=https://bsky.social
BLUESKY_IDENTIFIER=your-handle.bsky.social
BLUESKY_PASSWORD=your-app-password
Mastodon
Generate an access token in Preferences > Development > New Application on your Mastodon instance.
MASTODON_URL=https://mastodon.social
MASTODON_ACCESS_TOKEN=your-access-token
Requires an OAuth 2.0 token with w_member_social scope.
- Create an app at LinkedIn Developers
- Enable Share on LinkedIn and Sign In with LinkedIn using OpenID Connect
- Add
http://localhost:3847/callbackas an authorized redirect URL - Run
rake blurt:linkedin_authto authenticate
LINKEDIN_CLIENT_ID=your-client-id
LINKEDIN_CLIENT_SECRET=your-client-secret
LINKEDIN_ACCESS_TOKEN=your-oauth-token
LINKEDIN_PERSON_ID=your-person-id
LinkedIn tokens expire every 60 days. Run rake blurt:linkedin_auth to re-authenticate when you get a 401 error.
Medium
Generate an integration token.
MEDIUM_INTEGRATION_TOKEN=your-integration-token
Dev.to
Generate an API key in Settings > Extensions.
DEVTO_API_KEY=your-api-key
Substack
Uses SMTP to email posts to your Substack import address. Posts arrive as drafts.
SUBSTACK_SMTP_HOST=smtp.gmail.com
SUBSTACK_SMTP_PORT=587
[email protected]
SUBSTACK_SMTP_PASSWORD=your-app-password
[email protected]
[email protected]
Running
Development
# Start everything (server + worker)
bin/dev
# Or start separately
bin/rails server
bin/jobs
The worker polls queue/ every 60 seconds. To change the interval:
POLL_INTERVAL_MS=30000 # 30 seconds
Verify it works
# Check health (no auth needed)
curl http://localhost:3000/api/health
# Check configured platforms
curl http://localhost:3000/api/platforms \
-H "Authorization: Bearer $BLURT_API_KEY"
# Create and publish a test post
curl -X POST http://localhost:3000/api/posts \
-H "Authorization: Bearer $BLURT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"platforms":["bluesky"],"content":"Hello from Blurt!"}'
curl -X POST http://localhost:3000/api/posts/hello-from-blurt.md/publish \
-H "Authorization: Bearer $BLURT_API_KEY"
Docker
The fastest way to run Blurt in production. The included docker-compose.yml handles everything: image build, volume mounts, health checks, and auto-restart.
Quick start
git clone https://github.com/fberrez/blurt.sh.git
cd blurt.sh
cp .env.example .env
# Edit .env with your platform credentials
docker compose up -d
That's it. Drop markdown files in ./queue/ and Blurt publishes them. Published posts land in ./sent/ with permalinks in the frontmatter.
Volume mounts
Docker Compose bind-mounts three directories from the host so your posts stay on your machine:
| Host | Container | Purpose |
|---|---|---|
./queue/ |
/rails/queue/ |
Pending posts |
./sent/ |
/rails/sent/ |
Published posts (with permalinks) |
./failed/ |
/rails/failed/ |
Failed posts (with error details) |
The container runs as UID 1000. Files in queue/ must be owned by this user. If you push files to a remote server as root (e.g. via scp), fix the ownership so Blurt can process them:
scp my-post.md root@your-server:/opt/blurt/queue/
ssh root@your-server "chown -R 1000:1000 /opt/blurt/queue/"
SQLite databases are stored in a Docker named volume (blurt_storage) and persist across container restarts.
Managing the container
# Check health
curl http://localhost/api/health
# View logs
docker compose logs -f
# Stop
docker compose down
# Rebuild after pulling updates
docker compose up -d --build
Environment variables
Platform credentials are loaded from .env at the project root. The container also requires RAILS_MASTER_KEY (from config/master.key) passed via the RAILS_MASTER_KEY environment variable or in .env.
To change the host port (default 80), set BLURT_PORT:
BLURT_PORT=8080 docker compose up -d
Environment reference
| Variable | Required | Description |
|---|---|---|
BLURT_API_KEY |
For API | Bearer token for API authentication |
POLL_INTERVAL_MS |
No | Queue poll interval in ms (default: 60000) |
BLUESKY_IDENTIFIER |
For Bluesky | Your Bluesky handle |
BLUESKY_PASSWORD |
For Bluesky | App password |
BLUESKY_SERVICE |
No | AT Protocol service URL (default: https://bsky.social) |
MASTODON_URL |
For Mastodon | Instance URL |
MASTODON_ACCESS_TOKEN |
For Mastodon | Access token |
LINKEDIN_ACCESS_TOKEN |
For LinkedIn | OAuth 2.0 token |
LINKEDIN_PERSON_ID |
For LinkedIn | Your LinkedIn person URN |
LINKEDIN_CLIENT_ID |
For re-auth | OAuth client ID |
LINKEDIN_CLIENT_SECRET |
For re-auth | OAuth client secret |
MEDIUM_INTEGRATION_TOKEN |
For Medium | Integration token |
DEVTO_API_KEY |
For Dev.to | API key |
SUBSTACK_SMTP_HOST |
For Substack | SMTP server |
SUBSTACK_SMTP_PORT |
No | SMTP port (default: 587) |
SUBSTACK_SMTP_USER |
For Substack | SMTP username |
SUBSTACK_SMTP_PASSWORD |
For Substack | SMTP password |
SUBSTACK_FROM_ADDRESS |
For Substack | Sender email |
SUBSTACK_TO_ADDRESS |
For Substack | Substack import address |
File structure
blurt.sh/
queue/ # Drop markdown files here
sent/ # Published posts (with permalinks)
failed/ # Failed posts (with error details)
.env # Platform credentials
storage/ # SQLite databases (Solid Queue, PublishLog)
The queue/, sent/, and failed/ directories are the only state that matters. Back them up, and you have a complete record of everything you've ever published.