An API you can curl, or open in a browser, to receive Bluesky data as markdown!
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Docker support with automated CI publish

- Dockerfile: multi-stage build using Next.js standalone output
- docker-compose.yml: port 3010 default, configurable rate limits
- .tangled/workflows/docker.yml: Spindle CI pushes to j4ckxyz/bsky-md on every push to main
- next.config.ts: enable standalone output + fix turbopack root
- middleware.ts: rate limit values now read from env vars (RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS)
- README: full self-hosting section with one-liner, Compose, config, and update instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

jack 70f1415d 095801d1

+161 -4
+11
.dockerignore
··· 1 + .git 2 + .gitignore 3 + .next 4 + node_modules 5 + npm-debug.log* 6 + README.md 7 + .vercel 8 + .env* 9 + *.pem 10 + coverage 11 + og-image-card.png
+18
.tangled/workflows/docker.yml
··· 1 + when: 2 + - event: ["push"] 3 + branch: ["main"] 4 + 5 + dependencies: 6 + nixpkgs: 7 + - docker 8 + 9 + steps: 10 + - name: Build and push to Docker Hub 11 + command: | 12 + echo "$DOCKER_TOKEN" | docker login -u "$DOCKER_USERNAME" --password-stdin 13 + docker buildx create --use 14 + docker buildx build \ 15 + --platform linux/amd64,linux/arm64 \ 16 + --tag j4ckxyz/bsky-md:latest \ 17 + --push \ 18 + .
+43
Dockerfile
··· 1 + # syntax=docker/dockerfile:1 2 + 3 + # ── Stage 1: deps ───────────────────────────────────────────────────────────── 4 + FROM node:22-alpine AS deps 5 + WORKDIR /app 6 + 7 + COPY package.json package-lock.json ./ 8 + RUN npm ci 9 + 10 + # ── Stage 2: builder ────────────────────────────────────────────────────────── 11 + FROM node:22-alpine AS builder 12 + WORKDIR /app 13 + 14 + COPY --from=deps /app/node_modules ./node_modules 15 + COPY . . 16 + 17 + ENV NEXT_TELEMETRY_DISABLED=1 18 + RUN npm run build 19 + 20 + # ── Stage 3: runner ─────────────────────────────────────────────────────────── 21 + FROM node:22-alpine AS runner 22 + WORKDIR /app 23 + 24 + ENV NODE_ENV=production 25 + ENV NEXT_TELEMETRY_DISABLED=1 26 + 27 + # Non-root user for security 28 + RUN addgroup --system --gid 1001 nodejs \ 29 + && adduser --system --uid 1001 nextjs 30 + 31 + # Copy only the standalone output + static assets 32 + COPY --from=builder /app/public ./public 33 + COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 34 + COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 35 + 36 + USER nextjs 37 + 38 + EXPOSE 3010 39 + 40 + ENV PORT=3010 41 + ENV HOSTNAME=0.0.0.0 42 + 43 + CMD ["node", "server.js"]
+59 -1
README.md
··· 55 55 56 56 See [`/llms.txt`](https://bsky-md.vercel.app/llms.txt) for a machine-readable guide to every endpoint. 57 57 58 + ## Self-hosting with Docker 59 + 60 + Run your own instance on any machine — works on Mac, Linux, and Windows. Great paired with a [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) to make it publicly accessible without opening ports. 61 + 62 + ### Quick start (one-liner) 63 + 64 + ```bash 65 + docker run -d -p 3010:3010 --restart unless-stopped j4ckxyz/bsky-md:latest 66 + ``` 67 + 68 + Your instance is now running at **http://localhost:3010**. 69 + 70 + ### Docker Compose (recommended) 71 + 72 + Compose makes it easy to configure the port and rate limits. 73 + 74 + 1. Download the compose file: 75 + ```bash 76 + curl -O https://tangled.org/j4ck.xyz/bsky-md/raw/main/docker-compose.yml 77 + ``` 78 + 2. (Optional) Edit `docker-compose.yml` to adjust settings — see the table below. 79 + 3. Start it: 80 + ```bash 81 + docker compose up -d 82 + ``` 83 + 4. Stop it: 84 + ```bash 85 + docker compose down 86 + ``` 87 + 88 + ### Configuration 89 + 90 + Edit these values directly in `docker-compose.yml`, or pass them as environment variables: 91 + 92 + | Variable | Default | Description | 93 + |---|---|---| 94 + | `PORT` | `3010` | Host port to expose (e.g. `PORT=8080 docker compose up -d`) | 95 + | `RATE_LIMIT_MAX` | `10` | Max requests per IP per window | 96 + | `RATE_LIMIT_WINDOW_MS` | `60000` | Window size in milliseconds (default: 1 minute) | 97 + 98 + **Personal use?** Set `RATE_LIMIT_MAX=100` or remove the limit entirely. 99 + 100 + **Public instance?** Keep the defaults or lower `RATE_LIMIT_MAX` to protect Bluesky's API. 101 + 102 + ### Updating 103 + 104 + ```bash 105 + docker compose pull && docker compose up -d 106 + ``` 107 + 108 + ### Building from source 109 + 110 + ```bash 111 + git clone https://tangled.org/j4ck.xyz/bsky-md 112 + cd bsky-md 113 + docker compose up --build -d 114 + ``` 115 + 58 116 ## Stack 59 117 60 - Next.js 16 · TypeScript · `@atproto/api` · Deployed on Vercel 118 + Next.js 16 · TypeScript · `@atproto/api` · Deployed on Vercel · Docker: `j4ckxyz/bsky-md`
+23
docker-compose.yml
··· 1 + services: 2 + bsky-md: 3 + image: j4ckxyz/bsky-md:latest 4 + build: . 5 + ports: 6 + - "${PORT:-3010}:3010" 7 + restart: unless-stopped 8 + environment: 9 + - NODE_ENV=production 10 + 11 + # ── Rate limiting ─────────────────────────────────────────────────────── 12 + # Max requests per IP before returning 429. Default: 10/min. 13 + # Increase if you're the only user, decrease to protect Bluesky's API. 14 + - RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-10} 15 + # Rolling window in milliseconds. Default: 60000 (1 minute). 16 + - RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000} 17 + 18 + healthcheck: 19 + test: ["CMD-SHELL", "wget -qO- http://localhost:3010/ || exit 1"] 20 + interval: 30s 21 + timeout: 5s 22 + retries: 3 23 + start_period: 10s
+2 -2
middleware.ts
··· 5 5 // Each edge instance tracks its own state. Not globally coordinated, but 6 6 // sufficient to cap runaway single-IP abuse and protect Bluesky's API. 7 7 // --------------------------------------------------------------------------- 8 - const WINDOW_MS = 60_000 // 1 minute 9 - const MAX_REQUESTS = 10 // per IP per window 8 + const WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10) 9 + const MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX ?? '10', 10) 10 10 11 11 const ipWindows = new Map<string, number[]>() 12 12
+5 -1
next.config.ts
··· 1 1 import type { NextConfig } from "next"; 2 + import path from "path"; 2 3 3 4 const nextConfig: NextConfig = { 4 - /* config options here */ 5 + output: 'standalone', 6 + turbopack: { 7 + root: path.resolve(__dirname), 8 + }, 5 9 }; 6 10 7 11 export default nextConfig;