Self‑Hosted Markdown Kanban Board with Doska (Node.js)

Build a minimal clone of Doska using Node.js, Express, and SQLite

Welcome to another official UVF IT guide! In this quick‑start you’ll spin up a lightweight Kanban board that stores cards as Markdown files, inspired by the Doska project. We’ll use only free, open‑source tools so you can get a working prototype in under an hour.

We’ll scaffold a tiny Express app, define a SQLite schema for boards and cards, expose a simple JSON API, and serve a minimal HTML UI. The result is a functional clone you can run locally and extend as you like.

Before you start

Clone Architecture
LOCAL STACK SQL queries HTTP API calls Express Server (Node.js) API + UI SQLite DB cards.db Static HTML/JS public/
1

Phase 1: Scaffold the project

Create a fresh directory for your board and initialise it as an npm project. This gives you a package.json to track dependencies and scripts. Then install Express for the web server and better‑sqlite3 for fast, synchronous SQLite access.

We also add a basic folder layout: src/ for server code and public/ for static assets. This keeps the project tidy and mirrors how many Node.js starters are organised.

mkdir doska-clone && cd doska-clone
npm init -y
npm install express@4 better-sqlite3@8
mkdir -p src public
2

Phase 2: Define data model (SQLite)

On the first run the server will create a cards.db file with a single cards table. Each card stores its markdown content, the column it belongs to (e.g., "todo", "doing", "done"), and timestamps for creation and updates. Using SQLite means we avoid a separate database server – everything lives in one file, perfect for a lightweight clone.

initDb.js

import Database from 'better-sqlite3';

// Open (or create) the SQLite file in the project root
const db = new Database('cards.db');

// Create the cards table if it doesn't exist yet
const createTable = `
CREATE TABLE IF NOT EXISTS cards (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  markdown TEXT NOT NULL,
  column TEXT NOT NULL CHECK(column IN ('todo','doing','done')),
  created_at DATETIME DEFAULT (datetime('now')),
  updated_at DATETIME DEFAULT (datetime('now'))
);
`;

db.exec(createTable);

export default db;
3

Phase 3: Build API & minimal UI

In this step we wire up an Express server that talks to a SQLite database. The server will expose three simple REST endpoints: one to fetch all cards, one to create a new card, and one to move a card between columns. It also serves a tiny static HTML page that uses fetch to call those endpoints, so you can see the board in your browser.

We keep the UI minimal: three columns (Todo, In‑Progress, Done) and a form to add a new card. When a card is moved, we send a PATCH request to update its column. This gives you a working Kanban board without any fancy front‑end frameworks.

server.js

const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');

const app = express();
const db = new sqlite3.Database('kanban.db');

app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

// Initialise DB
db.serialize(() => {
  db.run(`CREATE TABLE IF NOT EXISTS cards (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    markdown TEXT,
    column TEXT NOT NULL DEFAULT 'Todo'
  )`);
});

// List all cards
app.get('/api/cards', (req, res) => {
  db.all('SELECT * FROM cards', [], (err, rows) => {
    if (err) return res.status(500).json({error: err.message});
    res.json(rows);
  });
});

// Create a new card
app.post('/api/cards', (req, res) => {
  const {title, markdown} = req.body;
  const stmt = db.prepare('INSERT INTO cards (title, markdown) VALUES (?, ?)');
  stmt.run(title, markdown || '', function(err) {
    if (err) return res.status(500).json({error: err.message});
    res.json({id: this.lastID, title, markdown, column: 'Todo'});
  });
});

// Move a card to a different column
app.patch('/api/cards/:id', (req, res) => {
  const {column} = req.body;
  const stmt = db.prepare('UPDATE cards SET column = ? WHERE id = ?');
  stmt.run(column, req.params.id, function(err) {
    if (err) return res.status(500).json({error: err.message});
    res.json({updated: this.changes});
  });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`🚀 http://localhost:${PORT}`));
4

Phase 4: Run locally

Now that the server code and UI are in place, start the application with Node. The server will automatically serve the public folder, so opening the root URL loads the board. You can keep the terminal open to watch any runtime errors.

node server.js
5

Phase 5: Verify and extend

Open http://localhost:3000 in your browser. You should see three column headings and a form at the top. Create a card, watch it appear in the "Todo" column, then use the move buttons to shift it to "In‑Progress" or "Done". From here you can enhance the UI with markdown rendering (e.g., the marked library) and add drag‑and‑drop using SortableJS, or persist each card as a .md file for true Doska‑style editing.

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Minimal Kanban</title>
  <style>
    body {font-family: Arial, sans-serif; margin: 20px;}
    .board {display: flex; gap: 10px;}
    .column {flex: 1; border: 1px solid #ccc; padding: 10px;}
    .card {background:#f9f9f9; margin:5px 0; padding:5px; border:1px solid #ddd;}
    button {margin-left:5px;}
  </style>
</head>
<body>
  <h1>Kanban Board</h1>
  <form id="newCardForm">
    <input type="text" id="title" placeholder="Card title" required />
    <input type="text" id="markdown" placeholder="Markdown (optional)" />
    <button type="submit">Add Card</button>
  </form>
  <div class="board" id="board">
    <div class="column" data-col="Todo"><h2>Todo</h2></div>
    <div class="column" data-col="In-Progress"><h2>In‑Progress</h2></div>
    <div class="column" data-col="Done"><h2>Done</h2></div>
  </div>
  <script>
    async function loadCards(){
      const res = await fetch('/api/cards');
      const cards = await res.json();
      document.querySelectorAll('.column').forEach(col=>col.innerHTML = `<h2>${col.dataset.col}</h2>`);
      cards.forEach(c=>addCardToDOM(c));
    }
    function addCardToDOM(card){
      const colDiv = document.querySelector(`.column[data-col="${card.column}"]`);
      const cardDiv = document.createElement('div');
      cardDiv.className='card';
      cardDiv.textContent = card.title;
      const moveBtn = document.createElement('button');
      moveBtn.textContent='→';
      moveBtn.onclick = async()=>{
        const next = { 'Todo':'In-Progress','In-Progress':'Done','Done':'Todo' }[card.column];
        await fetch(`/api/cards/${card.id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({column:next})});
        loadCards();
      };
      cardDiv.appendChild(moveBtn);
      colDiv.appendChild(cardDiv);
    }
    document.getElementById('newCardForm').addEventListener('submit', async e=>{
      e.preventDefault();
      const title = document.getElementById('title').value;
      const markdown = document.getElementById('markdown').value;
      await fetch('/api/cards',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,markdown})});
      document.getElementById('title').value='';
      document.getElementById('markdown').value='';
      loadCards();
    });
    loadCards();
  </script>
</body>
</html>

Verifying the Stack

CheckCommand / Why it matters
Server starts without errorsnode server.js → should log ‘🚀 http://localhost:3000’
UI loads in browserOpen http://localhost:3000 → page with three column headings appears
Create a card via APIcurl -X POST -H 'Content-Type: application/json' -d '{"title":"Test","markdown":"# Test"}' http://localhost:3000/api/cards → returns JSON with id

What’s next?

You now have a functional markdown‑based Kanban clone. Consider adding a markdown renderer (e.g., marked), drag‑and‑drop with SortableJS, and persisting each card as a .md file for true Doska‑style editing.