All articles
6 min·

From idea to deploy: building a production Discord bot with Bun and GitHub Actions

A full walkthrough of building EchoSwift, an open-source Discord bot that auto-posts Swift.org articles, with Bun, Docker and CI/CD.

Par Carolane Lefebvre
TypeScriptCI/CDGit
From idea to deploy: building a production Discord bot with Bun and GitHub Actions

Where the project came from

Keeping up with Swift.org news meant checking their site regularly. I saw an opportunity: build a Discord bot that automatically notifies my community of new articles.

What started as a simple script turned into a full production-ready project, with CI/CD, Docker, monitoring and DevOps best practices. Here is the development journey and the technical decisions behind it.

TL;DR: EchoSwift is an open-source Discord bot that automatically posts Swift.org articles, deployed with GitHub Actions and Docker.

The need

The Swift community moves fast: major announcements, language improvements, lessons learned on the official blog. How do you stay up to date without visiting the site multiple times a day?

The answer: a Discord bot that automatically polls the Swift.org feed (noon and midnight), detects new articles, posts them in a clean format with a role mention, and deduplicates via a tracking system.

Picking the stack

Bun as the runtime

Instead of Node.js, I went with Bun for a few reasons.

Performance: near-instant startup (versus ~500ms for Node.js), lower memory footprint (~30 MB versus ~50 MB), bundler and test runner built in.

bun install   # 10-20x faster than npm
bun run dev   # Native hot reload
bun test      # Ultra-fast test runner

Native TypeScript: no need for ts-node or compilation, Bun runs TypeScript directly.

Application architecture

src/
├── index.ts          # Entry point, init
├── config.ts         # Config and env validation
├── bot.ts            # Discord client and event handlers
├── scheduler.ts      # Cron jobs and orchestration
├── feed/
│    ├── fetcher.ts   # HTTP client with retry
│    ├── parser.ts    # Atom/RSS parsing
│    └── storage.ts   # JSON persistence with atomic writes
└── discord/
     ├── embeds.ts    # Discord embed creation
     └── poster.ts    # Posting with rate limiting

Each module owns a single responsibility, which makes tests and maintenance easier.

Technical challenges and fixes

Smart scheduler with node-cron

The challenge: run the bot at fixed times in the right timezone.

import cron from 'node-cron';
 
const cronPattern = '0 12,0 * * *'; // Noon and midnight
const task = cron.schedule(
  cronPattern,
  () => checkFeedAndPost(client, config),
  { timezone: config.timezone } // 'Europe/Paris'
);

In development, immediate execution to make testing easier. In production, wait for the cron to avoid stray posts.

Deduplication and atomic storage

The challenge: never repost the same articles after a restart.

async function savePostedArticles(articleIds: Set<string>): Promise<void> {
  const tempPath = STORAGE_PATH + '.tmp';
  const backupPath = STORAGE_PATH + '.backup';
 
  // 1. Backup the existing file
  if (existsSync(STORAGE_PATH)) {
    await copyFile(STORAGE_PATH, backupPath);
  }
 
  // 2. Write to a temporary file
  await writeFile(tempPath, JSON.stringify(data, null, 2));
 
  // 3. Atomic rename (atomic at the OS level)
  await rename(tempPath, STORAGE_PATH);
}

A cleanup pass removes entries older than 30 days to prevent unbounded growth.

Discord announcement channel support

The bot initially crashed on announcement channels (GuildAnnouncement). Fix: broaden the channel type check.

// Before (restrictive)
if (channel.type !== ChannelType.GuildText) {
  throw new Error('Not a text channel');
}
 
// After (flexible)
if (channel.type !== ChannelType.GuildText &&
    channel.type !== ChannelType.GuildAnnouncement) {
  throw new Error('Not a text or announcement channel');
}

Rate limiting and resilient posting

Discord caps at roughly 5 messages per 5 seconds. Fix: a 2-second wait between posts and error handling that continues with the next article when one fails.

for (let i = 0; i < articles.length; i++) {
  try {
    await postArticle(client, channelId, roleId, articles[i]);
    if (i < articles.length - 1) {
      await new Promise(resolve => setTimeout(resolve, 2000));
    }
  } catch (error) {
    console.error(`Error posting article ${i + 1}:`, error);
  }
}

Polished Discord embeds

Articles render with clean embeds: short description (200 characters max), clickable title, Swift orange colour, and publication timestamp.

return new EmbedBuilder()
  .setTitle(article.title)
  .setURL(article.link)
  .setDescription(description)
  .setColor(0xF05138)
  .setFooter({ text: `swift.org - Published ${dateStr}` })
  .setTimestamp(article.publishedAt);

DevOps: from code to deployment

Multi-stage Docker build

The goal: a small, secure image.

# Stage 1: Build
FROM oven/bun:1-alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY src ./src
COPY tsconfig.json ./
 
# Stage 2: Production
FROM oven/bun:1-alpine
WORKDIR /app
RUN addgroup -g 1001 -S botuser && \
    adduser -S -D -H -u 1001 -G botuser botuser
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/src ./src
COPY --from=builder /app/tsconfig.json ./
RUN mkdir -p /app/data && chown -R botuser:botuser /app/data
VOLUME ["/app/data"]
USER botuser
CMD ["bun", "run", "src/index.ts"]

Final image is roughly 100 MB (versus 300+ MB with Node.js), with hardened security (non-root user) and reproducible builds.

CI/CD with GitHub Actions

The full workflow: automatic tests with Bun, build and push of the Docker image to GHCR, automatic SSH deploy to the VPS, and container restart.

name: Build & Deploy
on:
  push:
    branches: [main]
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Bun
        uses: oven-sh/setup-bun@v1
      - name: Run tests
        run: bun test
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
 
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to VPS
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/echoswift
            sudo docker compose pull
            sudo docker compose up -d --force-recreate

Benchmarks

  • Memory: Bun ~30 MB vs Node.js ~50 MB (40% saving)
  • Startup: Bun ~50ms vs Node.js ~500ms (10x faster)
  • Docker cold start: Bun ~800ms vs Node.js ~2s (2.5x faster)

Lessons learned

What worked well: Bun (excellent for a light, fast project), atomic writes (data corruption prevention), CI/CD from day one (fast iteration), multi-stage builds (lightweight, secure images).

What I would do differently: add integration tests with a Discord mock, plug Sentry or Datadog for monitoring, set up HTTP health checks, collect metrics (articles posted, response times), and use PostgreSQL instead of JSON for scalability.

EchoSwift is much more than a simple Discord bot: it is a complete demonstration of how to build, test and deploy a production-ready application with modern technologies.

Commentaires

Connecte-toi pour laisser un commentaire.

Aucun commentaire pour le moment. Sois le premier !

More articles

.task vs .onAppear vs .refreshable en SwiftUI

iOS 26 a livré 5 améliorations discrètes en SwiftUI

safeAreaInset vs safeAreaBar en SwiftUI