DEV Community

Sabry Dawood
Sabry Dawood

Posted on

I built a self-hosted CI/CD platform with persistent queue, encrypted secrets, and rollback UI — here's what I learned

For the past several months I've been building Deploy Center, a self-hosted CI/CD deployment platform. v3.0 shipped recently, and I want to share the architecture decisions, what worked, and what I'd do differently.

TL;DR: https://github.com/FutureSolutionDev/Deploy-Center-Server — MIT licensed, TypeScript + Express + React + MySQL/MariaDB + Redis.

The problem I was trying to solve

Most small teams I've worked with deploy through one of these:

  1. A bash script triggered by a cron or a manual SSH
  2. GitHub Actions running ad-hoc scripts on the target server
  3. A heavyweight platform like Jenkins that nobody wants to maintain

The first two have no audit trail, no rollback, no concept of "who is allowed to deploy what." The third needs a dedicated person to keep it healthy.

I wanted something in between: the simplicity of "git push and it deploys" with the safety net of audit logs, RBAC, encrypted secrets, and one-click rollback.

Architecture overview

Three tiers, nothing exotic:

  • Frontend: React 19 + MUI + React Query + Socket.IO client
  • Backend: Express + TypeScript + Sequelize
  • Data: MySQL/MariaDB + Redis (for the queue)

The interesting parts are how the queue and the secrets work.

Persistent queue with BullMQ

Earlier versions used an in-memory queue. It worked fine until the process restarted mid-deployment — and then the deployment was just gone. No log, no retry, nothing.

v3.0 moved to BullMQ + Redis. The key behaviors:

  • Jobs persist across restarts. On boot, the server does a one-shot re-enqueue of any Queued deployment rows that don't have a matching active job.
  • Retry policy is 3 attempts with exponential backoff (1s → 5s → 25s).
  • There's a QueueReadyMiddleware that 503s API requests when Redis is unreachable, so the UI gets a clean error instead of silently dropping requests.
  • Bull Board is mounted at /admin/queues (Admin-only) for inspection.

Encrypted environment variables

Every project has its own env vars table. Each row is encrypted with AES-256-GCM with a unique IV, decrypted only at deploy time, and the values are redacted from logs by name.

The encryption key lives in .env as a 64-char hex string. Rotating it is a documented step (re-encrypt all rows under the new key).

The trade-off: if someone has shell access to the server and the env file, they have the keys. But for the "stolen DB dump" scenario — the most common breach vector for small teams — the secrets stay opaque.

RBAC: four roles + project membership

Two layers of permission:

  1. User role (system-wide): Admin, Manager, Developer, Viewer
  2. Project membership (per project): Owner, Member

A Developer can only see and deploy projects they're a member of. A Viewer can read logs but can't trigger anything. The permission matrix is in the README if you want the details.

Notifications: Provider / Channel / Subscription

This is the part I'm proudest of architecturally.

Most notification systems hard-code "this event goes to this webhook." Deploy Center splits it into three tables:

  • NotificationProvider — the credentials (one Discord workspace, one SMTP server)
  • NotificationChannel — a specific delivery target under a provider (channel ID, recipient list)
  • ProjectNotificationSubscription — M:N: which projects fire which events to which channels

So you can have one Discord provider with five channels, and each project subscribes to whichever channels make sense for it. Adding Slack support was just adding a new provider type — no changes to the project model.

Fan-out uses Promise.allSettled so one failing channel doesn't block the others. Each failure is logged with channel + provider context.

What I'd do differently

  • Should have started with BullMQ. The in-memory queue was technical debt from day one.
  • Sequelize migrations are painful. I'd consider Drizzle or Kysely on a future project.
  • Real-time via Socket.IO is fine for logs, but I'd evaluate Server-Sent Events first — they're simpler for one-way streams.

What's next (v3.1)

Remote deployment targets — right now Deploy Center deploys to the same machine it runs on. v3.1 will add SSH-based remote targets so you can run one Deploy Center instance and deploy to many servers.

The full roadmap is in docs/ROADMAP.md.

Try it / contribute

Repo: https://github.com/FutureSolutionDev/Deploy-Center-Server

It's MIT licensed. PRs welcome — CONTRIBUTING.md walks through the dev setup. The codebase is TypeScript strict mode, ESLint + Prettier configured, Jest on the server and Vitest on the client, GitHub Actions running typecheck + lint + tests on every PR.

If you try it and it breaks, please open an issue. If you try it and it works, a star would mean a lot.

Top comments (0)