rmendes.net

Deploying Your Own IndieWeb Site with Indiekit + Eleventy (Docker Compose based)

Original article Version 3 → 4
Content changed
Download image Bluesky

Changes

A complete guide to deploying Indiekit on your own server using Docker Compose. By the end of this guide, you’ll have a fully functional IndieWeb blog with automatic HTTPS, Micropub support, syndication to Mastodon and Bluesky, and a static Eleventy-powered frontend — all running on a domain you own. What You’ll Get A static blog generated by Eleventy with automatic rebuilds when you publish A Micropub server so you can post from any Micropub client Automatic HTTPS via Caddy and Let’s Encrypt POSSE syndication to Mastodon, Bluesky, and LinkedIn Webmention support for receiving likes, replies, and reposts Background jobs that handle syndication and webmention sending MongoDB for data storage, all running in Docker containers Prerequisites Server Setup Clone and Configure Write Your.env File Launch the Stack Create Your Admin Password Log In and Explore Write Your First Post Set Up Syndication Set Up Webmentions Enable the Full Plugin Set Backup and Restore Updating Troubleshooting Migrating from another Static Site Generator 1. Prerequisites You need: A server — any VPS with at least 1 GB RAM (2 GB recommended). DigitalOcean, Hetzner, Linode, or any provider works. A domain name — e.g., janedoe.me . You’ll need access to its DNS settings. Docker and Docker Compose v2 — installed on the server. Ports 80 and 443 open — Caddy needs these for automatic HTTPS. Install Docker (if needed) On Ubuntu/Debian: curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # Log out and back in for group change to take effect Verify: docker --version # Docker version 27.x.x docker compose version # Docker Compose version v2.x.x Point Your Domain Create a DNS A record pointing your domain to your server’s IP address: Type: A Name: @ (or janedoe.me) Value: 203.0.113.42 (your server IP) TTL: 300 If you also want www : Type: CNAME Name: www Value: janedoe.me TTL: 300 Wait for DNS to propagate (usually a few minutes, sometimes up to an hour). You can check with: dig janedoe.me +short # Should return: 203.0.113.42 2. Server Setup SSH into your server: ssh user@203.0.113.42 Make sure ports 80 and 443 are open: # If using ufw sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw allow 443/udp # HTTP/3 sudo ufw status 3. Clone and Configure git clone https://github.com/rmdes/indiekit-deploy.git cd indiekit-deploy Initialize the Eleventy theme submodule: make init This pulls the Eleventy theme that powers your site’s frontend. 4. Write Your.env File Copy the example and open it in your editor: cp .env.example .env nano .env # or vim, or whatever you prefer Here’s what a realistic .env looks like for a new deployment. We’ll start with the essentials and add syndication later: # ============================================================================= # Domain & Site # ============================================================================= DOMAIN=janedoe.me SITE_URL=https://janedoe.me SITE_NAME=Jane Doe SITE_DESCRIPTION=Thinking out loud on the open web. SITE_LOCALE=en SITE_TIMEZONE=Europe/Paris SITE_CATEGORIES=blog,tech,indieweb,personal,links # ============================================================================= # Author # ============================================================================= AUTHOR_NAME=Jane Doe AUTHOR_BIO=Web developer and IndieWeb enthusiast. I write about building things for the open web. AUTHOR_AVATAR=/images/default-avatar.svg AUTHOR_LOCATION=Paris, France AUTHOR_EMAIL=jane@janedoe.me # ============================================================================= # Social Links (two options — pick one) # ============================================================================= # Option A: Set individual handles (auto-generates social links) GITHUB_USERNAME=janedoe BLUESKY_HANDLE=janedoe.bsky.social MASTODON_INSTANCE=https://mastodon.social MASTODON_USER=@janedoe LINKEDIN_USERNAME=janedoe # Option B: Set SITE_SOCIAL directly for full control (overrides Option A) # Format: "Name|URL|icon,Name|URL|icon" # SITE_SOCIAL=GitHub|https://github.com/janedoe|github,Bluesky|https://bsky.app/profile/janedoe.bsky.social|bluesky,Mastodon|https://mastodon.social/@janedoe|mastodon,LinkedIn|https://www.linkedin.com/in/janedoe/|linkedin # ============================================================================= # Syndication — leave blank for now, we'll set these up later # ============================================================================= MASTODON_ACCESS_TOKEN= BLUESKY_PASSWORD= LINKEDIN_ACCESS_TOKEN= # ============================================================================= # Webmentions — leave blank for now # ============================================================================= WEBMENTION_IO_TOKEN= # ============================================================================= # Authentication — leave PASSWORD_SECRET blank for first run # ============================================================================= PASSWORD_SECRET= Save and close. Key things to get right: DOMAIN must exactly match your DNS A record (no https:// , no trailing slash) SITE_URL must be https:// + your domain SITE_TIMEZONE uses IANA timezone names like America/New_York , Europe/London , Asia/Tokyo Leave PASSWORD_SECRET empty — you’ll fill it in after the first launch 5. Launch the Stack For the core plugin set (recommended to start): make up Or equivalently: docker compose up -d This starts 5 containers: Container What it does mongodb Stores your posts, settings, and syndication state indiekit The Indiekit server — handles Micropub, auth, admin UI eleventy Watches for new posts and rebuilds your static site caddy HTTPS reverse proxy — serves your site to the world cron Runs syndication every 2 minutes and webmentions every 5 minutes Watch the logs to make sure everything starts: make logs You should see: caddy-1 | ... certificate obtained successfully indiekit-1 | ==> Indiekit entrypoint (profile: core) indiekit-1 | ==> Generating JWT secret indiekit-1 | ==> Starting Indiekit on port 8080 eleventy-1 | ==> Waiting for Indiekit at http://indiekit:8080... eleventy-1 | ==> Indiekit is ready eleventy-1 | ==> Building Eleventy site eleventy-1 | [11ty] Wrote 3 files in 0.42 seconds eleventy-1 | [eleventy-watcher] Starting watcher After about 30–60 seconds, your site should be live at https://janedoe.me . You’ll see a minimal blog with no posts yet. If you see TLS errors: your DNS might not have propagated yet. Wait a few minutes and check dig janedoe.me . Caddy retries automatically. 6. Create Your Admin Password This is the critical first-run step. You must do this before you can log in or publish anything. Step 1: Visit the Login Page Open https://janedoe.me/session/login in your browser. Since no password is set yet, Indiekit shows a “New password” page instead of a login form. Step 2: Create Your Password Enter a strong password and submit. Indiekit will: Hash your password using bcrypt Display the hash on screen — it looks like: $2b$10$Eujjehrmx.K.n92T3SFLJe/mN5ZQ4gHIvP.Y8rdBmqko9SLHG7K2u Tell you to save this as PASSWORD_SECRET Copy the full hash. You need every character. Step 3: Save the Hash in Your.env Open your .env file and find the PASSWORD_SECRET= line. Paste the hash, but escape every $ as $$ : # What Indiekit displayed: # $2b$10$Eujjehrmx.K.n92T3SFLJe/mN5ZQ4gHIvP.Y8rdBmqko9SLHG7K2u # What you put in .env (every $ doubled to $$): PASSWORD_SECRET=$$2b$$10$$Eujjehrmx.K.n92T3SFLJe/mN5ZQ4gHIvP.Y8rdBmqko9SLHG7K2u Why the double dollars? Docker Compose uses $ for variable substitution. A bare $10 in .env would be interpreted as an environment variable reference (and resolve to empty). Doubling them ($$ ) tells Docker Compose to treat them as literal $ characters. Step 4: Restart Indiekit docker compose restart indiekit Important: use restart , not down && up . A full down + up would also restart MongoDB and the other containers unnecessarily. Step 5: Verify Go to https://janedoe.me/session/login again. You should now see a normal login form. Enter your password. If it works, you’re in. If login fails: double-check that every $ in the hash is doubled in .env . A common mistake is missing the $$ before 10 (the bcrypt cost factor). The hash has exactly 5 $ characters, so your .env value should have exactly 5 $$ pairs. 7. Log In and Explore After logging in, you’ll see the Indiekit dashboard. From here you can: Create posts at /create — write notes, articles, bookmarks, etc. Manage posts at /posts — see all your published content View files at /files — browse the raw Markdown files on disk Check plugins at /plugins — see what’s loaded The admin UI is served by Indiekit at https://janedoe.me/session/login . Your public blog is the Eleventy-generated static site at https://janedoe.me/ . 8. Write Your First Post From the Admin UI Go to https://janedoe.me/create Select Note as the post type Write something: Hello from my new IndieWeb site! This is my first post, published via Micropub. #indieweb Click Publish Within a few seconds, Eleventy detects the new Markdown file, rebuilds the site, and your post appears at https://janedoe.me/ . From a Micropub Client You can also post from any Micropub client : Quill — web-based, great for quick notes Indigenous — iOS/Android app iA Writer — macOS/iOS, supports Micropub publishing To connect a Micropub client: Point it at https://janedoe.me The client discovers your Micropub endpoint automatically (via <link rel="micropub"> ) Authenticate with your domain (IndieAuth) Start posting 9. Set Up Syndication Syndication (POSSE — Publish on your Own Site, Syndicate Elsewhere) lets you cross-post to social networks automatically. Posts are created on your site first, then syndicated to Mastodon, Bluesky, or LinkedIn. Mastodon Go to your Mastodon instance (e.g., https://mastodon.social ) Navigate to Settings > Development > New Application Give it a name like “Indiekit” Grant scopes: read , write Save and copy the Access Token Add to your .env : MASTODON_INSTANCE=https://mastodon.social MASTODON_USER=@janedoe MASTODON_ACCESS_TOKEN=Ba91_xYzAbCdEfGhIjKlMnOpQrStUv0123456789abc Bluesky Go to bsky.app Navigate to Settings > App Passwords > Add App Password Name it “Indiekit” and copy the generated password Add to your .env : BLUESKY_HANDLE=janedoe.bsky.social BLUESKY_PASSWORD=abcd-1234-efgh-5678 Apply Changes After updating .env , restart Indiekit to pick up the new environment variables: docker compose restart indiekit The cron sidecar automatically runs syndication every 2 minutes. When you create a new post with syndication targets checked, it will be queued and syndicated on the next cron run. How Syndication Works You create a post at /create with syndication targets checked (Mastodon, Bluesky, etc.) The post is saved to disk as Markdown The cron sidecar POSTs to /syndicate every 2 minutes Indiekit picks up unsyndicated posts, posts them to each target, and saves the syndication URLs back to the post You can check syndication status at /posts — each post shows which targets it was syndicated to. 10. Set Up Webmentions Webmentions are how IndieWeb sites notify each other about links, likes, replies, and reposts. Sending Webmentions The cron sidecar automatically sends webmentions for your posts every 5 minutes. When you publish a post that links to another site, Indiekit sends a webmention to notify them. No configuration needed — this works out of the box. Receiving Webmentions To receive webmentions (likes, replies, reposts from other sites), use webmention.io : Sign up at webmention.io using your domain It discovers your site’s rel="me" links for authentication After signing in, go to Settings and copy your API Token Add to your .env : WEBMENTION_IO_TOKEN=your-webmention-io-api-token-here Restart: docker compose restart indiekit The Eleventy theme automatically displays webmentions (likes, replies, reposts) on each post page. 11. Enable the Full Plugin Set The core profile gives you a functional IndieWeb blog. The full profile adds extra integrations: Plugin What it does GitHub Shows your GitHub activity, stars, and contributions Funkwhale Displays listening history from a Funkwhale instance Last.fm Shows scrobbles, loved tracks, and listening stats YouTube Displays your YouTube channel activity RSS Aggregates and caches external RSS feeds Microsub Social reader with feed subscriptions Podroll Aggregates podcast episodes from FreshRSS Blogroll Manages a blogroll with OPML/Microsub import Homepage Configurable homepage sections CV Manage and display a resume/CV To switch to the full profile, stop the stack and start with the full override: docker compose down docker compose -f docker-compose.yml -f docker-compose.full.yml up -d Or use the Makefile shortcut: make down make up-full Important: the first time you switch to full profile, the Indiekit container is rebuilt with all plugins installed. This takes a few minutes. Add the relevant environment variables to your .env . Each plugin only activates when its required env vars are set — you only need to fill in the ones you want: # ─── GitHub ─── # Shows commits, stars, contributions on /github # Get a token at https://github.com/settings/tokens (scopes: read:user, repo) GITHUB_TOKEN=ghp_abc123def456ghi789jkl012mno345pqr678 GITHUB_FEATURED_REPOS=janedoe/my-cool-project,janedoe/another-repo # ─── Last.fm ─── # Shows scrobbles, loved tracks, top artists on /listening # Get an API key at https://www.last.fm/api/account/create LASTFM_API_KEY=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 LASTFM_USERNAME=janedoe # ─── YouTube ─── # Shows latest videos, live streaming status on /youtube # Get an API key from Google Cloud Console > APIs & Services > Credentials YOUTUBE_API_KEY=your-youtube-api-key-here YOUTUBE_CHANNELS=@janedoe-tech # ─── Funkwhale ─── # Shows listening history from a Funkwhale instance on /funkwhale FUNKWHALE_INSTANCE=https://funkwhale.example.com FUNKWHALE_USERNAME=janedoe FUNKWHALE_TOKEN=your-funkwhale-api-token # ─── Podroll ─── # Aggregates podcast episodes from a FreshRSS instance # These URLs come from your FreshRSS API (greader format for episodes, OPML for sidebar) PODROLL_EPISODES_URL=https://rss.example.com/api/query.php?user=jane&t=yourtoken&f=greader PODROLL_OPML_URL=https://rss.example.com/api/query.php?user=jane&t=yourtoken&f=opml # ─── LinkedIn Syndication ─── # Option 1 (recommended): Use the OAuth flow — visit /linkedin after launching # Set these to enable the OAuth endpoint: LINKEDIN_CLIENT_ID=77abc123def456 LINKEDIN_CLIENT_SECRET=WPLsecret123abc # Option 2: Set access token manually (expires after ~60 days) # LINKEDIN_ACCESS_TOKEN=AQV...long-token... # LINKEDIN_AUTHOR_NAME=Jane Doe # LINKEDIN_PROFILE_URL=https://www.linkedin.com/in/janedoe/ After updating .env , rebuild the full profile: make build-full make up-full LinkedIn OAuth Flow If you set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET , you can use the built-in OAuth flow instead of managing tokens manually: Create an app at the LinkedIn Developer Portal Set the redirect URI to https://janedoe.me/linkedin/callback Add LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET to .env Restart Indiekit Visit https://janedoe.me/linkedin and authorize The token is saved automatically and refreshed when needed 12. Backup and Restore Backup make backup This creates a compressed archive in backups/ containing: content/ — all your Markdown posts and media uploads/ — uploaded files mongodb/ — the full database config/ — your Indiekit config and JWT secret backups/indiekit-20260214-120000.tar.gz Automate it with a cron job on the host: crontab -e # Add: 0 3 * * * cd /home/user/indiekit-deploy && make backup Restore make restore FILE=backups/indiekit-20260214-120000.tar.gz This stops all services, restores the volumes from the archive, and restarts everything. 13. Updating When a new version of Indiekit or the theme is released: # Pull latest code cd ~/indiekit-deploy git pull # Update the Eleventy theme make update-theme # Rebuild images with new code make build # core profile # or make build-full # full profile # Restart with new images make up # core # or make up-full # full Using Pre-built Images If you prefer not to build locally, pre-built images are published to Docker Hub: docker compose pull # Pulls latest images from Docker Hub docker compose up -d # Starts with the pulled images Available images: Image Description rmdes/indiekit-deploy-server Indiekit server (full plugin set) rmdes/indiekit-deploy-site Eleventy static site builder rmdes/indiekit-deploy-cron Cron sidecar To pin a specific version (e.g., 1.0.0-beta.25 ), create a docker-compose.override.yml : services: indiekit: image: rmdes/indiekit-deploy-server:1.0.0-beta.25 eleventy: image: rmdes/indiekit-deploy-site:1.0.0-beta.25 cron: image: rmdes/indiekit-deploy-cron:1.0.0-beta.25 14. Troubleshooting “Blog coming soon” or “Building site…” Eleventy is still building (or the build failed). Check the logs: docker compose logs eleventy A successful build looks like: [11ty] Wrote 15 files in 1.23 seconds [eleventy-watcher] Starting watcher If it failed, look for Nunjucks template errors or missing files. Can’t log in after setting PASSWORD_SECRET The most common cause is incorrect $ escaping. Check your .env : # WRONG — Docker Compose interprets $10 as a variable PASSWORD_SECRET=$2b$10$Eujjehrmx... # CORRECT — every $ is doubled PASSWORD_SECRET=$$2b$$10$$Eujjehrmx... After fixing, you must fully recreate the container (not just restart): docker compose down indiekit docker compose up -d indiekit A docker compose restart does not re-read .env changes. You need a full down/up cycle. Verify DNS resolves: dig janedoe.me +short should return your server IP Verify ports are open: sudo ufw status should show 80 and 443 allowed Check Caddy logs: docker compose logs caddy Caddy retries certificate provisioning automatically. If DNS just propagated, wait a minute. Posts don’t appear on the site Eleventy watches the content directory and rebuilds automatically. If a post doesn’t appear: Check Eleventy logs: docker compose logs eleventy Look for [11ty] File changed messages The watcher auto-restarts with exponential backoff if it crashes Syndication not working Check cron logs: docker compose logs cron Verify your syndicator env vars are set correctly in .env Syndication runs every 2 minutes — check the timestamp in logs Make sure the JWT secret exists: docker compose exec cron cat /data/config/.secret MongoDB won’t start docker compose logs mongodb docker compose ps mongodb Common cause: disk full. Check with df -h . Environment variable changes don’t take effect docker compose restart does not re-read .env . You must: docker compose down indiekit # Stop and remove the container docker compose up -d indiekit # Recreate with new env View all running services make status # or docker compose ps Shell into a container make shell-indiekit # Indiekit container make shell-eleventy # Eleventy container make shell-cron # Cron container make shell-caddy # Caddy container Quick Reference Useful Commands make up # Start (core profile) make up-full # Start (full profile) make down # Stop everything make logs # Follow all logs make restart # Restart all services make status # Show running services make build # Rebuild images (core) make build-full # Rebuild images (full) make backup # Backup all data make update-theme # Pull latest theme Important URLs URL Purpose https://janedoe.me/ Your public blog https://janedoe.me/session/login Log in to admin https://janedoe.me/create Create a new post https://janedoe.me/posts Manage posts https://janedoe.me/files Browse content files https://janedoe.me/plugins View loaded plugins https://janedoe.me/feed.json JSON Feed https://janedoe.me/micropub Micropub endpoint Architecture Internet → Caddy :443 (auto HTTPS) ├── Static site (Eleventy) → /data/site └── API endpoints → Indiekit :8080 └── MongoDB :27017 Cron sidecar → syndicate every 2m, webmentions every 5m Data Volumes All your data lives in Docker volumes that persist across restarts and upgrades: Volume Contents content Your posts (Markdown files) uploads Uploaded media site Built static HTML (regenerated automatically) mongodb_data Database indiekit_config Config file + JWT secret caddy_data TLS certificates Migrating from another Static Site Generator Already running Hugo, Jekyll, or micro.blog? indiekit-deploy now ships with a content migration tool that moves your existing posts, media, and old URLs into your new Indiekit deployment without losing them. The tool runs as a profile-gated Docker service — no host-side Node or Python required: cp -r ~/old-blog/* migration/input/ make migrate-detect # auto-detects the SSG layout make migrate-convert FROM=hugo # transform → migration/staged/ make migrate-preview # diff against live volumes make migrate-apply # copy into content + uploads volumes docker compose restart caddy # activate URL redirects What gets migrated: Markdown content — articles, notes, photos, bookmarks, replies, page bundles Frontmatter — titles, dates, tags, response properties (like-of , bookmark-of , etc.) Media files — images, video, audio, copied with their original web paths preserved so unchanged markdown still resolves 301 redirects — every old URL gets a redirect to its canonical Indiekit URL, generated as a Caddy snippet that’s auto-imported The classifier auto-maps content into Indiekit’s post types (titled posts → article, untitled short bodies → note, frontmatter with bookmark-of: → bookmark, etc.). For edge cases, drop a migration/input/_classify.yaml file with glob-pattern overrides. For Hugo specifically, there’s a step-by-step guide covering page bundles, custom permalinks, shortcodes, and troubleshooting: Migrating from Hugo to Indiekit — full walkthrough with edge cases and verification checklist Migration tool overview — supported sources, classification rules, override syntax Jekyll and micro.blog adapters ship in v1 (untested with real exports yet); Ghost and WordPress (WXR) are on the roadmap . What’s Next? Customize your theme — the Eleventy theme supports dark mode, RSS feeds, sitemaps, and social embeds out of the box. Fork the theme repo to customize. Add rel=“me” links — verify your social profiles by adding rel="me" links. The theme generates these from your GITHUB_USERNAME , MASTODON_INSTANCE , and BLUESKY_HANDLE env vars. Connect Bridgy — use brid.gy to backfeed responses from Mastodon and Bluesky as webmentions. Join the IndieWeb — add yourself to the IndieWeb wiki , join the chat , and attend an Homebrew Website Club meetup. Editorial assistance — article drafted by human, AI helped with structure and clarity Learn more about AI usage on this site