IH.

Connect

Kanboard Developer & Deployment Guide

Kanboard β€” My Complete Build & Deployment Documentation

An AI-Powered, Real-Time Kanban Board β€” PERN Stack, Dockerized, Deployed on Azure

This is my consolidated engineering journal for Kanboard β€” everything from the product vision I started with, through the architecture and data model I designed, the UI system I built, the bugs I hit and fixed, all the way to how I actually deploy it to production. I wrote this so that six months from now (or anyone picking this project up) can read one document and understand not just what the system does, but why I made the decisions I made.


πŸ“– Table of Contents

  1. My Vision for This Project
  2. Quick Start β€” Running It Locally
  3. Project Structure
  4. System Architecture
  5. Database Schema
  6. Authentication System
  7. Business Rules & Access Control
  8. MVP Scope β€” What I Built vs. What I Deferred
  9. UI & Design System
  10. User Journey & Flows
  11. How I Think About the Codebase (Patterns I Follow)
  12. From Development to Deployment
  13. Bugs I Hit and How I Fixed Them
  14. My Manual Deployment Runbook
  15. Automating My Deployment with CI/CD
  16. Closing Notes

1. My Vision for This Project

I built Kanboard because I was frustrated with existing project management tools. My vision was to create a premium, AI-native collaboration platform that eliminates the friction of project management β€” combining clean aesthetics with real-time, multi-tenant collaboration and Google Gemini AI, so that teams can go from β€œidea to structured execution” in seconds.

The problem I was trying to solve

I noticed that traditional tools like Jira or Trello suffer from two problems I wanted to avoid:

  1. The Blank Slate Problem β€” creating a new board normally means manually mapping out tasks, writing detailed tickets, assessing priorities, and assigning deadlines. That mental friction delays project kickoffs, and I wanted to remove it.
  2. Visual Noise β€” most modern tools are cluttered with heavy configuration, custom fields, and constant notifications. I wanted the opposite: something calm and focused.

The three principles I designed around

A. AI-native workflows, not an afterthought. I didn’t want AI bolted on as a chat sidebar. Instead, I integrated it directly into the core workflow β€” I built an AI Task Generator so that instead of spending hours writing cards, I can give it a goal like β€œBuild a Stripe payment system” and it immediately populates a complete roadmap. I also built an AI Sprint Summary that acts like an automated project manager, summarizing priorities and risks in one click, so I never have to stare at a big board wondering what’s blocked.

B. Exceptional visual design, with restraint. I believe tool aesthetics directly affect how productive and focused I feel while using something every day, so I designed a curated premium light theme: soft lavender off-white pages to reduce eye strain, surfaces that visually β€œfloat” with subtle layered shadows, restrained and purposeful accent colors, generous rounded corners, and modern display typography (Space Grotesk + Inter).

C. Multi-tenant, real-time speed. I didn’t want project management to require manual page refreshes. Every drag, drop, and edit I make propagates to all board members in under 100ms, which keeps remote teams aligned during live planning sessions.

Who I’m building this for

  • Software engineers and founders who want to spin up a quick, structured roadmap for a side project or startup without configuring Jira.
  • Product managers who need a clean, distraction-free interface to brainstorm user stories and organize workflows visually.
  • Small agile teams working remotely who need synchronous collaboration.

2. Quick Start β€” Running It Locally

This is how I get the app running on my own machine.

Prerequisites

  • Node.js (v20+)
  • Docker & Docker Compose

Steps I follow

1. Clone my repository:

git clone https://github.com/Injamulhasan/ai-kanban-board.git
cd ai-kanban-board

2. Start my local PostgreSQL database:

docker compose up -d

3. Set up my environment files:

Backend (server/.env):

DATABASE_URL=postgresql://postgres:postgres@localhost:5432/kanboard
JWT_SECRET=your-secret-key
GEMINI_API_KEY=your-gemini-api-key
PORT=5050
CLIENT_URL=http://localhost:5173

Frontend (.env):

VITE_API_URL=http://localhost:5050/api
VITE_SOCKET_URL=http://localhost:5050

4. Install my dependencies:

npm install                      # root & frontend
npm install --prefix server      # backend

5. Seed my database:

npm --prefix server run seed

6. Launch the application:

npm run dev

This starts both my React frontend (port 5173) and my Express backend (port 5050) concurrently.

7. Log in with my demo account:

  • Email: alex@kanboard.dev
  • Password: Test@1234

3. Project Structure

This is how I’ve organized my repository:

ai-kanban-board/
β”œβ”€β”€ Caddyfile                   # Production web server config
β”œβ”€β”€ docker-compose.yml          # Local database compose configuration
β”œβ”€β”€ docker-compose.prod.yml     # Production full-stack compose configuration
β”œβ”€β”€ package.json                # Root frontend scripts & concurrently setup
β”œβ”€β”€ docs/                       # My developer documentation & system context
β”‚   β”œβ”€β”€ dev2deploy.md           # My detailed developer-to-deployment guide
β”‚   └── architecture.md         # My system flows and architecture notes
β”œβ”€β”€ server/                     # My backend API server
β”‚   β”œβ”€β”€ Dockerfile              # Production backend container build script
β”‚   β”œβ”€β”€ package.json            # Server script configurations & dependencies
β”‚   └── src/                    # My backend source (Controllers, Services, Routes, DB)
└── src/                        # My frontend source (React Components, Hooks, Context)

4. System Architecture

4.1 The stack I chose

I built Kanboard as a monorepo:

  • Frontend: React (Vite, TailwindCSS, @dnd-kit/sortable)
  • Backend: Express.js REST API with Socket.IO for WebSocket events
  • Database: PostgreSQL
  • AI Integration: Google Gemini SDK (gemini-2.0-flash)

4.2 High-level architecture

I containerized everything as a three-tier architecture for execution consistency:

graph TD
    Client[Browser Client - React SPA]
    WebServer[Caddy Web Server - Reverse Proxy]
    API[Express Server - Backend API]
    DB[(PostgreSQL Database)]
    Gemini[Google Gemini API]

    Client -->|Port 80/443: HTTP / WS| WebServer
    WebServer -->|Static files| Client
    WebServer -->|Proxy API to Port 5050| API
    WebServer -->|Proxy WebSockets to Port 5050| API
    API -->|Port 5432| DB
    API -->|HTTPS Request| Gemini

4.3 My core user flows

Register / Login: I have users create an account, and I encrypt their password using bcrypt (12 rounds) on the backend. My backend issues a JWT containing the user ID, name, and email. My frontend stores this JWT in localStorage and appends it as a Bearer token in my Axios HTTP headers.

Board access & real-time sync: When a user enters a board, I initialize a Socket.IO connection. The socket handshakes with my backend using the JWT. Upon joining, the client enters a virtual Socket.IO room named board:<id> so I can isolate communication per board.

Presence: When other users join the same board, they emit presence:join events. I keep an active connection list synchronized on the client so I can show who’s currently working on the board.

4.4 Local development vs. production β€” how I keep them isolated

I made a strict rule for myself: never let local and production environments blur together.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚        LOCAL DEVELOPMENT        β”‚        β”‚      AZURE PRODUCTION VM        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€        β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ API URL: http://localhost:5050  β”‚        β”‚ API URL: http://<VM_IP>/api     β”‚
β”‚ DB Host: localhost (Docker)     β”‚        β”‚ DB Host: database (Internal net)β”‚
β”‚ SSL: None                       β”‚        β”‚ SSL: None (Handles HTTP on 80)  β”‚
β”‚ Env: server/.env                β”‚        β”‚ Env: VM server/.env             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why I run Docker locally:

  1. Isolation β€” instead of installing PostgreSQL directly on my Windows machine (which leaves persistent background services running), I run PostgreSQL inside an isolated Docker sandbox.
  2. Seed capability β€” running npm run seed drops and recreates my tables locally within a second, so I can test freely without ever risking my cloud data.
  3. Consistency β€” Docker guarantees the PostgreSQL engine on my local machine behaves identically to the one running on my Azure server.

4.5 Core operational lifecycles I designed

Drag-and-drop reordering. I use optimistic UI updates combined with backend persistence so dragging feels instantaneous:

sequenceDiagram
    participant User as User UI
    participant Hook as useBoard.js
    participant Server as Express API
    participant DB as PostgreSQL
    participant Room as Socket.IO Room

    User->>Hook: Drag card from Column A to Column B
    Note over Hook: Calculate new position parameter (midpoint between adjacent cards)
    Hook->>User: Instantly update UI (Optimistic update)
    Hook->>Server: HTTP PATCH /api/boards/:id/tasks/:taskId/move { column_id, position }
    Server->>DB: UPDATE tasks SET column_id = $1, position = $2 WHERE id = $3
    DB->>Server: Return updated task
    Server->>Room: Broadcast "task:moved" event to all members in board room
    Server->>Hook: HTTP 200 Response
    Note over User: If request fails, roll UI back to original state

Live presence tracking. I use Socket.IO rooms to track who’s actively viewing a board:

sequenceDiagram
    participant Client as React Client
    participant Socket as Socket.IO Client
    participant Server as Socket.IO Server
    participant Presence as Presence Manager

    Client->>Socket: Join Board (Board ID)
    Socket->>Server: Emit "board:join" (boardId)
    Note over Server: Authenticate socket JWT token
    Server->>Server: Join socket to room "board:<boardId>"
    Server->>Presence: Add user to board presence mapping
    Server->>Client: Emit "presence:sync" (current active user list)
    Server->>Client: Broadcast "presence:join" (user details) to other sockets in room
    Client->>Client: Render presence avatars in top navigation bar

Google Gemini AI generation flow. I query the gemini-2.0-flash model using structured JSON prompts:

sequenceDiagram
    participant Client as React Client
    participant API as Express API
    participant Gemini as Gemini API
    participant DB as PostgreSQL

    Client->>API: POST /api/boards/:id/ai/generate-tasks { goal, count }
    Note over API: Compile prompt enforcing structured JSON response shapes
    API->>Gemini: Request generateContent (Prompt text)
    Gemini->>API: Return JSON-formatted response
    Note over API: Parse and validate JSON list
    API->>DB: Batch INSERT generated tasks into Column 1
    DB->>API: Return inserted records
    API->>Client: Return list of created tasks (HTTP 200)

4.6 Why I chose Caddy over the alternatives

I use Caddy as my production web server and reverse proxy on my Azure VM. Here’s the comparison I made when deciding:

Web ServerStrengthsWeaknessesMy Decision
CaddyAutomatic HTTPS (Let’s Encrypt out-of-the-box); super clean config syntax; built-in WebSocket supportSlightly smaller community than NginxI chose this. Extremely simple to set up, handles Socket.IO without manual WebSocket headers, and automatically manages SSL for domains.
NginxIndustry standard; high performance; extremely low memory footprintVerbose configs; WebSockets need manual HTTP header overrides; SSL requires installing certbot + cron jobsI considered this as my alternative β€” great for high traffic, but more configuration overhead than I wanted.
TraefikAuto-discovers containers via Docker labels; designed for microservicesCan’t serve static files directly (needs Nginx/Caddy behind it for /dist)I didn’t choose this β€” too complex for my single-VM monorepo deployment.
ExpressAllows a single container setupNode.js is single-threaded; serving large static assets blocks the CPU and degrades my API/WebSocket speedI didn’t choose this β€” it violates separation of concerns; I always want static assets offloaded to a compiled web server.

4.7 Why I don’t run PM2 inside Docker

In a standard bare-metal VM deployment, PM2 would be mandatory to keep the Node process alive after I log out of SSH. But inside Docker, I treat PM2 as redundant, because:

  1. Container supervision β€” Docker itself acts as my supervisor. The restart: always directive in my docker-compose.prod.yml means that if the Node process crashes inside the container, Docker automatically restarts the container for me.
  2. Daemonization β€” running docker compose up -d detaches the process and runs it in the background natively.
  3. Logs β€” Docker captures stdout/stderr natively, so I inspect logs with docker logs kanboard-backend instead of pm2 logs.

4.8 Docker on Azure VM β€” the tradeoffs I accepted

Advantages I get:

  • Infrastructure ownership β€” I have complete root access to the OS, so I can tweak PostgreSQL parameters, inspect network logs, and configure security tools myself.
  • Vertical scalability β€” I can upgrade my VM size (e.g. B1s β†’ D2s) in one click, and Docker adapts automatically to the new CPU/RAM without any config edits.
  • Horizontal scalability β€” I have the option to host my PostgreSQL on a managed instance (like Supabase or Azure Database for PostgreSQL) and run multiple backend containers behind an Azure Load Balancer.

Drawbacks I accept:

  • Manual security β€” I’m responsible for upgrading the Ubuntu OS, patching vulnerabilities, and configuring the firewall myself.
  • Single point of failure β€” if my single VM goes down, both my database and web server go offline together.

5. Database Schema

I designed a relational schema in PostgreSQL with 6 tables, foreign keys, and cascading deletions.

erDiagram
    USERS {
        uuid id PK
        varchar name
        varchar email UK
        varchar password
        text avatar_url
        timestamptz created_at
    }
    BOARDS {
        uuid id PK
        varchar title
        text description
        varchar color
        uuid owner_id FK
        timestamptz created_at
        timestamptz updated_at
    }
    BOARD_MEMBERS {
        uuid board_id PK, FK
        uuid user_id PK, FK
        varchar role
        timestamptz joined_at
    }
    COLUMNS {
        uuid id PK
        uuid board_id FK
        varchar title
        double_precision position
        timestamptz created_at
    }
    TASKS {
        uuid id PK
        uuid board_id FK
        uuid column_id FK
        varchar title
        text description
        varchar priority
        date due_date
        double_precision position
        uuid assignee_id FK
        uuid created_by FK
        timestamptz created_at
        timestamptz updated_at
    }
    ACTIVITIES {
        uuid id PK
        uuid board_id FK
        uuid user_id FK
        varchar action
        text message
        jsonb meta
        timestamptz created_at
    }

    USERS ||--o{ BOARDS : owns
    USERS ||--o{ BOARD_MEMBERS : member_of
    BOARDS ||--|{ BOARD_MEMBERS : has
    BOARDS ||--|{ COLUMNS : contains
    BOARDS ||--|{ TASKS : contains
    COLUMNS ||--o{ TASKS : holds
    BOARDS ||--o{ ACTIVITIES : logs
    USERS ||--o{ TASKS : assigned_to

5.1 users

My table for storing user profile information and authentication credentials.

ColumnData TypeConstraintsDescription
idUUIDPRIMARY KEY, default uuid_generate_v4()Unique user identifier
nameVARCHAR(200)NOT NULLFull name of the user
emailVARCHAR(320)NOT NULL, UNIQUEUnique email for authentication
passwordVARCHAR(200)NOT NULLBcrypt-hashed password
avatar_urlTEXTNULLOptional link to user avatar image
created_atTIMESTAMPTZNOT NULL, default now()Registration timestamp

5.2 boards

My table defining Kanban workspaces.

ColumnData TypeConstraintsDescription
idUUIDPRIMARY KEY, default uuid_generate_v4()Unique board identifier
titleVARCHAR(200)NOT NULLBoard title
descriptionTEXTNULLBoard purpose or metadata
colorVARCHAR(20)default #2f8159Primary aesthetic color (hex code)
owner_idUUIDNOT NULL, REFERENCES users(id) ON DELETE CASCADECreator of the board
created_atTIMESTAMPTZNOT NULL, default now()Creation timestamp
updated_atTIMESTAMPTZNOT NULL, default now()Last modification timestamp

5.3 board_members

My intermediate lookup table mapping users to boards with specific permissions.

ColumnData TypeConstraintsDescription
board_idUUIDPRIMARY KEY, REFERENCES boards(id) ON DELETE CASCADEThe target board
user_idUUIDPRIMARY KEY, REFERENCES users(id) ON DELETE CASCADEThe member user
roleVARCHAR(20)default member, check (owner, admin, member)Access rights of the member
joined_atTIMESTAMPTZNOT NULL, default now()Join timestamp

5.4 columns

My table representing stages in the Kanban pipeline.

ColumnData TypeConstraintsDescription
idUUIDPRIMARY KEY, default uuid_generate_v4()Unique column identifier
board_idUUIDNOT NULL, REFERENCES boards(id) ON DELETE CASCADEThe parent board
titleVARCHAR(200)NOT NULLStage name (e.g. β€œTodo”)
positionDOUBLE PRECISIONNOT NULL, default 1000Sorting order within the board
created_atTIMESTAMPTZNOT NULL, default now()Creation timestamp

5.5 tasks

My table for storing actionable tickets.

ColumnData TypeConstraintsDescription
idUUIDPRIMARY KEY, default uuid_generate_v4()Unique task identifier
board_idUUIDNOT NULL, REFERENCES boards(id) ON DELETE CASCADEThe parent board
column_idUUIDNOT NULL, REFERENCES columns(id) ON DELETE CASCADECurrent pipeline stage
titleVARCHAR(500)NOT NULLTask summary
descriptionTEXTNULLDetailed description or subtasks list
priorityVARCHAR(20)default medium, check (low, medium, high, urgent)Importance ranking
due_dateDATENULLTask deadline
positionDOUBLE PRECISIONNOT NULL, default 1000Sorting order within the column
assignee_idUUIDREFERENCES users(id) ON DELETE SET NULLAssigned teammate
created_byUUIDREFERENCES users(id) ON DELETE SET NULLTask creator
created_atTIMESTAMPTZNOT NULL, default now()Creation timestamp
updated_atTIMESTAMPTZNOT NULL, default now()Modification timestamp

5.6 activities

My table for tracking system events for board audit trails.

ColumnData TypeConstraintsDescription
idUUIDPRIMARY KEY, default uuid_generate_v4()Unique activity identifier
board_idUUIDNOT NULL, REFERENCES boards(id) ON DELETE CASCADEAffected board
user_idUUIDREFERENCES users(id) ON DELETE SET NULLUser who triggered the action
actionVARCHAR(100)NOT NULLEvent type (e.g. task.created)
messageTEXTNOT NULLDescription of the action (human readable)
metaJSONBdefault {}Optional payload (old/new values)
created_atTIMESTAMPTZNOT NULL, default now()Event timestamp

5.7 Performance indexes I added

To keep my queries fast as data grows, I applied these indexes:

-- Speeds up board membership verification & dashboard loading
CREATE INDEX idx_board_members_user ON board_members(user_id);

-- Optimizes column listing for active boards
CREATE INDEX idx_columns_board     ON columns(board_id);

-- Speeds up task listing & cards rendering
CREATE INDEX idx_tasks_board       ON tasks(board_id);
CREATE INDEX idx_tasks_column      ON tasks(column_id);

-- Speeds up My Tasks panel loading (filter by assignee)
CREATE INDEX idx_tasks_assignee    ON tasks(assignee_id);

-- Speeds up activity feed loading (latest events first)
CREATE INDEX idx_activities_board  ON activities(board_id, created_at DESC);

-- Optimizes user verification during login
CREATE INDEX idx_users_email       ON users(email);

6. Authentication System

I built my authentication around JSON Web Tokens (JWT) and bcrypt password hashing.

6.1 Security infrastructure

  • Algorithm: HMAC SHA-256 for signing tokens
  • Bcrypt rounds: 12 salt rounds for password hashing during registration
  • Token payload: { id, email, name } (I never store passwords or sensitive DB columns in the token)
  • Token storage: Saved in the browser’s localStorage under the key kanban_token

6.2 Registration flow β€” POST /api/auth/register

  1. Client submits { name, email, password }.
  2. My backend sanitizes the parameters and verifies the email isn’t already registered.
  3. I encrypt the password using bcrypt.hash(password, 12).
  4. I insert the user row into the users table.
  5. I optionally generate a default welcome board for the user.
  6. I generate a JWT using jwt.sign().
  7. My backend responds with { user: { id, name, email }, token }.

6.3 Login flow β€” POST /api/auth/login

  1. Client submits { email, password }.
  2. My backend queries the database for the user row by email.
  3. I compare the submitted password against the stored hash using bcrypt.compare().
  4. On a match, I generate a JWT token.
  5. I respond with { user, token }.

6.4 Auto-login on refresh β€” GET /api/auth/me

  1. When my React client loads, AuthContext.jsx checks for kanban_token in localStorage.
  2. If a token exists, it makes an HTTP GET request to /api/auth/me with header Authorization: Bearer <token>.
  3. My authenticate middleware (server/src/middleware/auth.js) intercepts the request: it verifies the token signature using JWT_SECRET, extracts the user details from the payload, and attaches them to req.user.
  4. My controller fetches fresh user details from the database and returns { user }.
  5. If the token is expired or invalid, I return 401 Unauthorized and my frontend clears the token from storage.

6.5 WebSocket handshake authentication

I also authenticate my Socket.IO connections during the initial handshake:

  1. When a user logs in successfully, my frontend socket manager calls connectSocket().
  2. The client passes the JWT token inside the auth payload:
    const socket = io(URL, {
      auth: { token: getToken() }
    });
  3. My Socket.IO backend runs a middleware on connection:
    io.use((socket, next) => {
      const token = socket.handshake.auth?.token;
      // Verifies token...
      socket.user = { id, name, email };
      next();
    });
  4. If authentication fails, I reject the connection. This ensures only authenticated users can join board rooms.

7. Business Rules & Access Control

7.1 Role-based access control (RBAC)

Every board member I create is assigned a specific role (owner, admin, or member). Here’s the permission matrix I enforce:

Feature / ActionOwnerAdminMember
Delete Boardβœ…βŒβŒ
Transfer Ownershipβœ…βŒβŒ
Rename / Recolor Boardβœ…βœ…βŒ
Invite New Membersβœ…βœ…βŒ
Remove Members (Self)βœ… (must transfer ownership first)βœ…βœ…
Remove Other Membersβœ…βœ… (cannot remove owner)❌
Change Member Rolesβœ…βœ… (cannot change owner)❌
Create / Delete Columnsβœ…βœ…βŒ
Rename Columnsβœ…βœ…βŒ
Reorder Columnsβœ…βœ…βŒ
Create / Delete Tasksβœ…βœ…βœ…
Edit Task Content / Assignβœ…βœ…βœ…
Move Tasks (Drag & Drop)βœ…βœ…βœ…
Use Gemini AI Functionsβœ…βœ…βœ…

7.2 Data integrity & validation rules I enforce

Board & column constraints:

  • Every board must have at least one owner β€” I never allow the owner’s membership row to be deleted unless another member has already been promoted to owner.
  • Column names must be unique within the same board (case-insensitive) β€” I don’t allow two columns named β€œTodo” on the same board.
  • Positions of columns and tasks are computed as double-precision floating-point numbers. If the gap between two items shrinks below 1e-9, I automatically trigger a re-spacing transaction that resets positions to intervals of 1000.

Task assignment constraints:

  • A task can only be assigned to a user who is an active member of that task’s parent board β€” I filter the assignee dropdown on the backend to enforce this.
  • If I remove a board member, I set assignee_id = NULL on any tasks that were assigned to them on that board (enforced via database constraints and services).

AI safety & rate limits:

  • I cap Gemini AI requests at 15 requests per minute per user to prevent cost inflation.
  • AI-generated tasks are capped at a maximum of 15 tasks per request.
  • Task descriptions generated by my breakdown service are limited to 500 characters to conserve token usage.

8. MVP Scope β€” What I Built vs. What I Deferred

8.1 Core features I shipped in the MVP

User Authentication

  • Registration with validated parameters (Name, unique Email, secure Password)
  • Password security via bcrypt hashing
  • Session security via signed JWT
  • Automatic token re-validation on page refresh
  • Route guards on /dashboard and /board/*

Boards Management

  • Create boards with title, optional description, and a primary brand color
  • Rename and recolor boards dynamically
  • Delete boards (cascades to members, columns, tasks, activities)
  • Dashboard sections split into β€œMy Boards” and β€œShared with You”
  • Live counts of active tasks per board on the dashboard

Columns Pipeline

  • Add, rename, delete columns on a board
  • Custom sort ordering per column using floating-point positions

Tasks (Tickets)

  • Full CRUD on task cards
  • Detailed modal: title, rich text description, priority (low/medium/high/urgent), due date selector, assignee dropdown
  • Midpoint floating-point calculations for O(1) drag-and-drop ordering persistence

Collaboration & Sharing

  • Team members panel with search-by-email
  • Invite members with role designations (owner, admin, member, each scoped per Β§7.1)
  • Activity logs feed showing recent board actions

Google Gemini AI Engine

  • AI Task Generator β€” creates a structured task backlog from a text prompt goal
  • AI Task Breakdown β€” deconstructs a task’s description into a subtask checklist inside the modal
  • AI Board Summary β€” analyzes status, completed tickets, active work, and blockers into a roadmap summary

8.2 What I deliberately left out of the MVP

I excluded these to keep my focus on reliability first:

  • OAuth Single Sign-On (Google, GitHub, Microsoft)
  • File attachment uploads (PNG/PDF) inside the task modal
  • Comments system with @mentions
  • Time tracking (start/stop timers on cards)
  • Gantt chart & calendar views
  • Email notifications for assignments/comments

9. UI & Design System

9.1 My theme & color system

I use a curated, premium light theme, and I deliberately avoid generic colors (plain red, plain blue) β€” everything routes through my design tokens.

TokenValueUsage
bg-page#f8f7faSoft, calming lavender off-white page background
bg-surface#ffffffPure white β€” surfaces should look like they float above the page
text-brand#635bffVibrant indigo/lavender brand accent
text-ink#0e0d12Pitch black β€” headings, titles
text-muted#5c5a66Soft charcoal β€” labels, metadata, body text
text-faint#a2a0abWarm gray β€” inactive icons, placeholders
border-line#efedf5Thin, crisp borders

Shadows β€” restraint plus depth. I never use harsh, dark shadows; I always use soft, multi-layered shadows to convey elevation:

--shadow-soft: 0 2px 8px -1px rgba(14, 13, 18, 0.03), 0 8px 24px -4px rgba(14, 13, 18, 0.05);
--shadow-brand: 0 4px 14px 0 rgba(99, 91, 255, 0.35);

9.2 Typography

I combine a modern display typeface with a highly legible body typeface:

  1. Display font (headers, titles): Space Grotesk β€” used for board headers, card titles, hero text; confident, slightly geometric, tight tracking.
  2. Body font (paragraphs, code, details): Inter β€” clean and highly legible at small sizes.

Font styles I use consistently:

  • Page Heading: font-display text-2xl font-bold tracking-tight text-ink
  • Task Card Title: font-display text-sm font-semibold text-ink
  • Metadata Label: font-sans text-xs font-medium text-muted

9.3 Spacing & layout rules

  • Border radius: I use generous rounding throughout β€”
    • Cards & Modals: rounded-3xl (24px)
    • Buttons & Inputs: rounded-full (9999px) or rounded-2xl (16px) for larger buttons
    • Avatars: rounded-full
  • Layout container:
    • Sidebar: collapsible, w-64 expanded / w-16 collapsed
    • Board wrapper: horizontal scrolling list, padding px-6 py-6
  • Micro-interactions:
    • Hovering over any card: subtle upward translation + deepened shadow β€” transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-soft)]
    • Buttons scale down slightly when pressed β€” active:scale-[0.98] transition-transform

10. User Journey & Flows

10.1 The standard journey I designed

graph TD
    A[Landing Page] -->|Click Register| B[Register Account]
    B -->|Automatic Redirect| C[Dashboard Workspace]
    C -->|Click Create Board| D[Configure Board Modal]
    D -->|Create| E[Board Workspace]
    E -->|Click AI Tasks| F[Gemini Generation]
    F -->|Insert| G[Tasks Hydrated]
    G -->|Drag & Drop| H[Sprint Progress]
    G -->|Click Card| I[Task Modal - Assign / Due Date]
    E -->|Click Share| J[Invite Members]

10.2 Scenario 1 β€” Onboarding & my first board

  1. Registration: A new user visits http://localhost:5173/ and sees my landing page highlighting the Gemini AI features.
  2. Account creation: They click β€œStart now” and enter name, email, and password. On successful registration, the JWT is saved and the client transitions to /dashboard.
  3. Create board: They click Create Board. A modal asks for Title (e.g. β€œQ3 Launch Plan”), Description (β€œLaunch roadmap”), and Color Theme (e.g. violet).
  4. Auto-redirect: The new board is created, and the user is redirected to /board/:id. I automatically create four default columns: Todo, In Progress, Review, Done.

10.3 Scenario 2 β€” Planning with AI

  1. Generate backlog: On an empty board, the user clicks AI Tasks.
  2. Input goal: A prompt modal asks β€œWhat is your project goal?” β€” they enter β€œCreate a mobile delivery application”.
  3. Draft roadmap: They select a task count (e.g. 8) and click Generate. My Express server contacts Gemini, receives a parsed list, and inserts the tasks into the Todo column.
  4. Deconstruct a task: They click the generated card β€œSet up Google Maps integration”, opening the task modal, then click AI Breakdown.
  5. Subtasks check: Gemini generates 5 subtasks (e.g. β€œGet API key”, β€œInstall SDK”, β€œConfigure permissions”), which I append to the task’s description as a checkable markdown checklist.

10.4 Scenario 3 β€” Real-time team collaboration

  1. Invite member: The user clicks Members in the board’s top bar, searches diego@kanboard.dev, and invites him as an Admin.
  2. Teammate joins: Diego logs in on his own machine. Under β€œShared with You” on his dashboard, he sees the new board and opens it.
  3. Live presence: Diego’s avatar appears in the top navigation bar of the first user’s browser, showing that Diego is active on this board.
  4. Task assignment: The first user opens the β€œMaps Integration” task and assigns it to Diego Santos. Diego’s browser updates in real time, showing his avatar on the card.
  5. Move task: Diego drags the card from Todo into In Progress. It slides across the first user’s screen automatically.

11. How I Think About the Codebase (Patterns I Follow)

I wrote this section as a reminder to myself (and anyone else touching the code) about the conventions I’ve committed to, so I don’t accidentally break them later.

11.1 Critical entry points I always check first

  • Root package.json β€” root scripts; runs concurrently to boot both dev servers.
  • vite.config.js β€” handles React compiling and Tailwind integration.
  • server/src/index.js β€” my Express app server bootstrap and Socket.IO initialization.
  • server/src/socket.js β€” event handlers, authentication handshake, and presence mappings.
  • src/lib/api.js β€” my Axios HTTP client; signature matches the mock API logic.
  • src/lib/socket.js β€” WebSocket connections and event listeners on the frontend.

11.2 Unified route param naming

To make sure mergeParams: true works correctly in my Express nested routers, I always mount sub-routes using the exact segment name my controllers expect.

Correct mount pattern:

router.use("/:boardId/tasks", requireBoardMember, taskRoutes);

Access in the task controller: req.params.boardId (not id, and not lost).

11.3 Multi-tenant guard

Every route that mutates or fetches board-specific data must be guarded by requireBoardMember in boards.js. This automatically verifies that the authenticated user (req.user.id) is listed in the board_members table for the matching board. I never skip this, even for β€œread-only” routes.

11.4 WebSocket broadcasts

After any database mutation inside a controller (creating/updating a task or column), I always broadcast the change to the corresponding board’s Socket.IO room:

getIO().to(`board:${boardId}`).emit("task:created", task);

11.5 Floating-point positions for drag & drop

Tasks and columns are sorted by the position column. I never use integer-based ranking (1, 2, 3, …) and I never trigger bulk position updates. When a card is dropped between card A (position X) and card B (position Y), I calculate the new position as (X + Y) / 2. This gives me O(1) updates on the database.

11.6 Local database actions I use day-to-day

  • Start PostgreSQL container: docker compose up -d
  • Reset & seed tables: npm run seed --prefix server

11.7 Starting my dev servers

I run npm run dev in the root workspace, which starts:

  • Vite frontend on http://localhost:5173
  • Express backend on http://localhost:5050

12. From Development to Deployment

This section is my in-depth reference log of the complete engineering lifecycle β€” every design decision, architectural consideration, and troubleshooting history I went through on the way to a working production deployment.

I already covered the architecture, the Caddy decision, the PM2 decision, and the Docker/Azure tradeoffs in Β§4 above. What follows is everything else I learned along the way: the bugs I actually hit, my manual deployment steps, and how I eventually automated the whole thing.


13. Bugs I Hit and How I Fixed Them

During development and integration, I ran into three issues that took real debugging time. I’m documenting them here so I never have to re-diagnose them from scratch.

13.1 Fix 1 β€” Login credentials autofill mismatch

What went wrong: My frontend’s β€œUse demo account” button was hardcoded to autofill alex@timetoprogram.com from old mock data, but my database seed script actually populated alex@kanboard.dev. This meant clicking the demo button returned β€œInvalid credentials” every time.

How I fixed it: I updated Login.jsx to autofill the correct email:

const fillDemo = () =>
  setForm({ email: "alex@kanboard.dev", password: "Test@1234" });

13.2 Fix 2 β€” Lost Express route parameters (mergeParams)

What went wrong: Accessing nested routes (like column creation and task moves) returned null value in column "board_id" violates not-null constraint. I eventually traced this to the fact that Express routers don’t merge parent-router parameters by default, so req.params.boardId was coming back undefined.

How I fixed it: I updated the parent router mounts in boards.js to use the parameter name :boardId instead of :id. This let Express’s mergeParams: true correctly capture the ID inside my child routers:

-router.use("/:id/tasks", requireBoardMember, taskRoutes);
+router.use("/:boardId/tasks", requireBoardMember, taskRoutes);

This is now enshrined as a hard rule for myself β€” see Β§11.2.

13.3 Fix 3 β€” Caddy try_files overwriting POST requests (405 error)

What went wrong: Login requests on my live server returned Request failed with status code 405. I dug in and found that in Caddy, try_files is evaluated before reverse_proxy by default. Caddy was rewriting /api/auth/login to the static /index.html file, and then the static file server threw a 405 because POST isn’t allowed on static files.

How I fixed it: I grouped my Caddy routes into mutually exclusive handle blocks so /api/* and /socket.io/* bypass the static file server entirely:

:80 {
    handle /api/* {
        reverse_proxy backend:5050
    }
    handle /socket.io/* {
        reverse_proxy backend:5050
    }
    handle {
        root * /usr/share/caddy
        try_files {path} /index.html
        file_server
    }
}

14. My Manual Deployment Runbook

These are the exact commands I run to deploy updates manually.

Step 1 β€” Build my frontend locally

On my local PC, I update .env with the VM’s public IP:

VITE_API_URL=http://<YOUR_VM_PUBLIC_IP>/api
VITE_SOCKET_URL=http://<YOUR_VM_PUBLIC_IP>

Then I run the build:

npm run build

Step 2 β€” Compress and upload my files

I zip only the deployable assets (excluding local node_modules):

powershell -Command "Compress-Archive -Path server/src, server/package.json, server/package-lock.json, server/Dockerfile, dist, docker-compose.prod.yml, Caddyfile -DestinationPath deploy.zip -Force"

Then I transfer the archive to my VM:

scp -i "C:\path\to\key.pem" deploy.zip kanboard@<YOUR_VM_PUBLIC_IP>:~/kanboard/deploy.zip

Step 3 β€” Deploy on the VM

I SSH into the VM, extract the files, organize the folders, and restart Docker:

ssh -i "C:\path\to\key.pem" kanboard@<YOUR_VM_PUBLIC_IP>

# Inside the VM:
cd ~/kanboard
unzip -o deploy.zip && rm deploy.zip
mkdir -p server && mv src package.json package-lock.json Dockerfile server/
docker compose -f docker-compose.prod.yml up -d --build

15. Automating My Deployment with CI/CD

To stop doing the manual runbook every time, I set up a full GitHub Actions pipeline so my VM updates automatically whenever I push code to main. Once this was in place, a single git push would trigger a build, compile my React frontend, package the code, upload it to my Azure VM, and restart my container services β€” with zero downtime and zero manual steps on my end.

Step 1 β€” Actions I had to take myself first

Before the GitHub Actions workflow could connect to my Azure VM, I had to add two secrets inside my GitHub repository settings.

1. Navigating to GitHub Secrets: I went to my repository β€” https://github.com/Injamulhasan/ai-kanban-board β†’ Settings (top tabs) β†’ Secrets and variables (left sidebar) β†’ Actions.

2. Adding SSH_PRIVATE_KEY:

  • I clicked New repository secret (top right).
  • Name: SSH_PRIVATE_KEY
  • Secret: I copied the entire text content of my local key file, C:\Users\injam\Downloads\kanboard-vm_key.pem β€” including the -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY----- lines.
  • I clicked Add secret.

3. Adding VM_PUBLIC_IP:

  • I clicked New repository secret again.
  • Name: VM_PUBLIC_IP
  • Secret: 104.214.171.72
  • I clicked Add secret.

Step 2 β€” My workflow file

I created .github/workflows/deploy.yml to define the steps for compilation, zipping, transferring, and restarting my services:

name: Deploy Kanboard to Azure VM

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set Up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - name: Install Frontend Dependencies
        run: npm ci

      - name: Build Frontend Assets
        env:
          VITE_API_URL: http://${{ secrets.VM_PUBLIC_IP }}/api
          VITE_SOCKET_URL: http://${{ secrets.VM_PUBLIC_IP }}
        run: npm run build

      - name: Create Deploy Zip
        run: zip -r deploy.zip server/src server/package.json server/package-lock.json server/Dockerfile dist docker-compose.prod.yml Caddyfile

      - name: Copy Files to Azure VM via SSH
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.VM_PUBLIC_IP }}
          username: kanboard
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: "deploy.zip"
          target: "~/kanboard/"

      - name: Execute Deploy Commands on VM
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VM_PUBLIC_IP }}
          username: kanboard
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd ~/kanboard
            # Extract new deployment archive
            unzip -o deploy.zip && rm deploy.zip

            # Reorganize directory structure for docker-compose context
            mkdir -p server && mv src package.json package-lock.json Dockerfile server/

            # Restart docker container services
            docker compose -f docker-compose.prod.yml up -d --build

Now every push to main triggers: build my frontend β†’ zip the deployable assets β†’ copy them to my VM over SSH β†’ extract, reorganize, and restart my Docker containers β€” all without me touching the VM directly.

Step 3 β€” Watching my first automated run

Once I pushed the workflow file, my repository kicked off its first automated run. I could monitor the live progress, logs, and completion status myself here:

πŸ”— GitHub Actions Dashboard

What I now know this workflow does automatically on every push to main:

  1. Builds & compiles β€” it installs my frontend node modules, injects the VM public IP from my GitHub secrets, and builds my React app into dist/.
  2. Packages β€” it packs the built static assets (dist/), my backend server files (server/), docker-compose.prod.yml, and Caddyfile into deploy.zip.
  3. Transfers β€” it uses SSH secure copy (scp-action) to send that zip file to my Azure VM.
  4. Deploys & restarts β€” it connects to my VM over SSH (ssh-action), extracts the zip file, organizes the files, and runs docker compose up -d --build to apply the updates to my running container stack with minimal downtime.

Step 4 β€” How I tested it

Automated verification I did:

  • I checked the Actions tab on GitHub to confirm the build ran end-to-end: it logged into my VM, built the Docker image, and exited with status Success.

Manual verification I did on top of that:

  1. I made a small change in a file on my computer (something as simple as a test comment, or a UI tweak like a title/color label β€” anything I could visually confirm).
  2. I ran:
    git add .
    git commit -m "feat: test automated deploy pipeline"
    git push
  3. I waited for the build to complete in the Actions Dashboard β€” it usually takes about 60–90 seconds.
  4. I checked http://104.214.171.72 to verify my change was actually live.

That two-layer check β€” trusting the green checkmark in Actions, but also eyeballing the real site β€” is what gives me confidence that a push to main really does mean β€œit’s live,” not just β€œthe pipeline said so.”


16. Closing Notes

This document is my attempt to capture not just what Kanboard is, but the reasoning behind every major decision I made β€” why I chose Caddy over Nginx, why PM2 doesn’t belong inside my Docker setup, why I split roles into owner/admin/member the way I did, and every bug that taught me something about my own stack.

If I come back to this project later, or hand it off to someone else, my hope is that reading this top to bottom gives them (or future me) the same mental model I was carrying while I built it β€” not just the β€œhow,” but the β€œwhy.”