
Цель
Создать красивую Kanban-доску с веб-интерфейсом на Rust + Axum, без Docker и без Node.js, с аккуратной панелью, перетаскиванием задач, и кнопкой удаления.
Всё приложение собирается в один бинарник и запускается как systemd-сервис.
Что будем использовать
- Rust — язык программирования
- Axum — веб-фреймворк
- Askama — шаблонизатор
- SQLx — работа с SQLite
- Tailwind CSS — стили (в виде обычного CSS)
- Vanilla JS — перетаскивание задач и удаление
- systemd — запуск как сервис

Структура проекта
/srv/apps/kanban-board/
├── Cargo.toml
├── sqlx-data.json
├── migrations/
│ └── 001_initial.sql
├── src/
│ ├── main.rs
│ ├── state.rs
│ ├── models.rs
│ ├── db.rs
│ └── routes/
│ ├── mod.rs
│ ├── index.rs
│ └── api.rs
├── templates/
│ ├── base.html
│ └── index.html
└── static/
├── style.css
└── script.js
Подготовка окружения
1. Создаём системного пользователя kanban
sudo adduser --system --group --home /srv/apps/kanban kanban
sudo mkdir -p /srv/apps/kanban
sudo chown kanban:kanban /srv/apps/kanban
2. Устанавливаем зависимости
sudo apt update
sudo apt install -y build-essential pkg-config libssl-dev libsqlite3-dev
3. Переходим под пользователя kanban
sudo -u kanban -H bash
3. Устанавливаем Rust для пользователя kanban
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
rustc --version
cargo --version
Создание проекта и структуры файлов
1. Создаём проект
cd /srv/apps/kanban
cargo new kanban-board
cd kanban-board
2. Создаём структуру каталогов
mkdir -p migrations src/routes templates static
Все файлы проекта
1. Cargo.toml
[package]
name = "kanban-board"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["fs"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
askama = "0.12"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] }
2. sqlx-data.json
touch sqlx-data.json
3. migrations/001_initial.sql
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
4. src/models.rs
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub title: String,
pub description: Option<String>,
pub status: TaskStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Todo,
InProgress,
Done,
}
impl Default for TaskStatus {
fn default() -> Self {
TaskStatus::Todo
}
}
#[derive(Deserialize)]
pub struct CreateTask {
pub title: String,
pub description: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateTask {
pub status: Option<TaskStatus>,
pub title: Option<String>,
pub description: Option<String>,
}
src/state.rs
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct AppState {
pub db_pool: SqlitePool,
}
src/db.rs
use sqlx::{SqlitePool, Row};
use crate::models::{Task, TaskStatus};
pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)"
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_tasks(pool: &SqlitePool) -> Result<Vec<Task>, sqlx::Error> {
let rows = sqlx::query(
"SELECT id, title, description, status, created_at, updated_at FROM tasks ORDER BY created_at ASC"
)
.fetch_all(pool)
.await?;
let mut tasks = Vec::new();
for row in rows {
let status_str: String = row.try_get("status")?;
let status = match status_str.as_str() {
"in_progress" => TaskStatus::InProgress,
"done" => TaskStatus::Done,
_ => TaskStatus::Todo,
};
tasks.push(Task {
id: row.try_get("id")?,
title: row.try_get("title")?,
description: row.try_get("description")?,
status,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
});
}
Ok(tasks)
}
pub async fn create_task(pool: &SqlitePool, title: &str, description: Option<&str>) -> Result<Task, sqlx::Error> {
let id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO tasks (id, title, description, status) VALUES (?, ?, ?, ?)"
)
.bind(&id)
.bind(title)
.bind(description)
.bind("todo")
.execute(pool)
.await?;
let task = get_task_by_id(pool, &id).await?;
Ok(task)
}
pub async fn update_task(
pool: &SqlitePool,
id: &str,
status: Option<TaskStatus>,
title: Option<&str>,
description: Option<&str>,
) -> Result<Task, sqlx::Error> {
if let Some(s) = status {
let status_str = match s {
TaskStatus::Todo => "todo",
TaskStatus::InProgress => "in_progress",
TaskStatus::Done => "done",
};
sqlx::query("UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(status_str)
.bind(id)
.execute(pool)
.await?;
}
if let Some(t) = title {
sqlx::query("UPDATE tasks SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(t)
.bind(id)
.execute(pool)
.await?;
}
if let Some(desc) = description {
sqlx::query("UPDATE tasks SET description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(desc)
.bind(id)
.execute(pool)
.await?;
}
let task = get_task_by_id(pool, id).await?;
Ok(task)
}
async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Task, sqlx::Error> {
let row = sqlx::query("SELECT id, title, description, status, created_at, updated_at FROM tasks WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await?;
let status_str: String = row.try_get("status")?;
let status = match status_str.as_str() {
"in_progress" => TaskStatus::InProgress,
"done" => TaskStatus::Done,
_ => TaskStatus::Todo,
};
Ok(Task {
id: row.try_get("id")?,
title: row.try_get("title")?,
description: row.try_get("description")?,
status,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
}
pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
src/routes/mod.rs
pub mod index;
pub mod api;
src/routes/index.rs
use axum::{extract::State, response::Html};
use askama::Template;
use crate::{state::AppState, db};
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
tasks: Vec<crate::models::Task>,
}
pub async fn index(State(state): State<AppState>) -> Html<String> {
let tasks = db::get_tasks(&state.db_pool).await.unwrap_or_default();
let tpl = IndexTemplate { tasks };
Html(tpl.render().unwrap())
}
src/routes/api.rs
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use crate::{db, models::{CreateTask, UpdateTask}, state::AppState};
pub async fn get_tasks(State(state): State<AppState>) -> Result<Json<Vec<crate::models::Task>>, StatusCode> {
let tasks = db::get_tasks(&state.db_pool).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(tasks))
}
pub async fn create_task(
State(state): State<AppState>,
Json(payload): Json<CreateTask>,
) -> Result<Json<crate::models::Task>, StatusCode> {
let task = db::create_task(&state.db_pool, &payload.title, payload.description.as_deref())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(task))
}
pub async fn update_task(
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<UpdateTask>,
) -> Result<Json<crate::models::Task>, StatusCode> {
let task = db::update_task(
&state.db_pool,
&id,
payload.status,
payload.title.as_deref(),
payload.description.as_deref(),
).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(task))
}
pub async fn delete_task(
Path(id): Path<String>,
State(state): State<AppState>,
) -> Result<StatusCode, StatusCode> {
db::delete_task(&state.db_pool, &id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
src/main.rs
use axum::{Router, routing::{get, put}};
use sqlx::{SqlitePool};
use tower_http::services::ServeDir;
mod models;
mod db;
mod state;
mod routes;
use crate::state::AppState;
use crate::routes::{index::index, api};
#[tokio::main]
async fn main() {
let database_url = "sqlite:data.db";
let pool = SqlitePool::connect(database_url).await.expect("Failed to connect to DB");
db::init_db(&pool).await.expect("Failed to init DB");
let app_state = AppState { db_pool: pool };
let app = Router::new()
.route("/", get(index))
.route("/api/tasks", get(api::get_tasks).post(api::create_task))
.route("/api/tasks/:id", put(api::update_task))
.route("/api/tasks/:id", axum::routing::delete(api::delete_task))
.nest_service("/static", ServeDir::new("static"))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8082")
.await
.unwrap();
println!("🚀 Kanban Board running on http://0.0.0.0:8082");
axum::serve(listener, app).await.unwrap();
}
templates/base.html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban Board</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
<script src="/static/script.js"></script>
</body>
</html>
templates/index.html
{% extends "base.html" %}
{% block content %}
<div class="board">
<h1>Моя Kanban-доска</h1>
<div class="add-task">
<input type="text" id="newTaskTitle" placeholder="Название задачи">
<textarea id="newTaskDesc" placeholder="Описание"></textarea>
<button onclick="addTask()">Добавить задачу</button>
</div>
<div class="columns">
<div class="column">
<h2>📋 В работе</h2>
<div class="tasks" id="todo" ondrop="drop(event)" ondragover="allowDrop(event)"></div>
</div>
<div class="column">
<h2>🔄 Выполняется</h2>
<div class="tasks" id="in_progress" ondrop="drop(event)" ondragover="allowDrop(event)"></div>
</div>
<div class="column">
<h2>✅ Выполнено</h2>
<div class="tasks" id="done" ondrop="drop(event)" ondragover="allowDrop(event)"></div>
</div>
</div>
</div>
{% endblock %}
static/style.css
:root {
--bg-dark: #0f172a;
--bg-card: #1e293b;
--text-light: #f1f5f9;
--accent-blue: #3b82f6;
--accent-green: #10b981;
--accent-red: #ef4444;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background-color: var(--bg-dark);
color: var(--text-light);
}
.container {
padding: 20px;
}
.board h1 {
text-align: center;
margin-bottom: 30px;
color: #93c5fd;
}
.add-task {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 30px;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
.add-task input, .add-task textarea {
padding: 10px;
border-radius: 6px;
border: 1px solid #475569;
background-color: var(--bg-card);
color: var(--text-light);
}
.add-task button {
padding: 10px 15px;
background-color: var(--accent-blue);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.columns {
display: flex;
gap: 20px;
justify-content: center;
}
.column {
background-color: #334155;
border-radius: 8px;
width: 300px;
padding: 15px;
min-height: 500px;
}
.column h2 {
margin-top: 0;
text-align: center;
color: #c7d2fe;
}
.tasks {
min-height: 450px;
padding: 10px;
}
.task {
background-color: var(--bg-card);
border-radius: 6px;
padding: 12px;
margin-bottom: 10px;
cursor: grab;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
border-left: 4px solid var(--accent-blue);
position: relative;
}
.task[data-status="in_progress"] {
border-left-color: var(--accent-green);
}
.task[data-status="done"] {
border-left-color: var(--accent-red);
}
.task:hover {
opacity: 0.9;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 5px;
}
.task-title {
font-weight: bold;
margin-bottom: 0;
flex-grow: 1;
}
.delete-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 16px;
padding: 0;
margin-left: 8px;
}
.delete-btn:hover {
color: var(--accent-red);
}
.task-desc {
font-size: 0.9em;
color: #cbd5e1;
}
static/script.js
let tasks = [];
async function loadTasks() {
const response = await fetch('/api/tasks');
tasks = await response.json();
renderTasks();
}
function renderTasks() {
const columns = { todo: [], in_progress: [], done: [] };
tasks.forEach(task => {
columns[task.status].push(task);
});
['todo', 'in_progress', 'done'].forEach(status => {
const container = document.getElementById(status);
container.innerHTML = '';
columns[status].forEach(task => {
const taskEl = document.createElement('div');
taskEl.className = 'task';
taskEl.draggable = true;
taskEl.dataset.id = task.id;
taskEl.dataset.status = task.status;
taskEl.ondragstart = drag;
taskEl.innerHTML = `
<div class="task-header">
<div class="task-title">${task.title}</div>
<button class="delete-btn" onclick="deleteTask('${task.id}')">✕</button>
</div>
<div class="task-desc">${task.description || ''}</div>
`;
container.appendChild(taskEl);
});
});
}
async function addTask() {
const title = document.getElementById('newTaskTitle').value;
const desc = document.getElementById('newTaskDesc').value;
if (!title) return;
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: desc || null })
});
if (response.ok) {
document.getElementById('newTaskTitle').value = '';
document.getElementById('newTaskDesc').value = '';
loadTasks();
}
}
async function deleteTask(id) {
if (!confirm('Вы уверены, что хотите удалить задачу?')) return;
const response = await fetch(`/api/tasks/${id}`, {
method: 'DELETE'
});
if (response.ok) {
loadTasks();
}
}
function allowDrop(ev) {
ev.preventDefault();
}
function drag(ev) {
ev.dataTransfer.setData("text", ev.target.closest('.task').dataset.id);
}
function drop(ev) {
ev.preventDefault();
const id = ev.dataTransfer.getData("text");
const newStatus = ev.target.id;
fetch(`/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
}).then(() => loadTasks());
}
loadTasks();
Сборка и запуск
Собираем проект
cargo build --release

✅ Если вы видите предупреждение field tasks is never read, это нормально — askama использует поле в шаблоне.
Убеждаемся, что все файлы на месте
ls -la templates/
ls -la static/
Создаём файл базы данных заранее
⚠️ Важно: SQLite не может создать файл data.db, если он не существует и нет прав на запись. Поэтому создадим его заранее:
touch /srv/apps/kanban/kanban-board/data.db
chmod 664 /srv/apps/kanban/kanban-board/data.db
Запуск вручную
./target/release/kanban-board
После запуска приложение будет доступно по адресу:
🚀 Kanban Board running on http://0.0.0.0:8082

Откройте в браузере http://192.168.1.xxx:8082 — доска готова!
- ✅ Кнопка удаления задач
- ✅ Перетаскивание задач
systemd-сервис
Создаём файл сервиса:
sudo nano /etc/systemd/system/kanban-board.service
[Unit]
Description=Kanban Board
After=network.target
[Service]
Type=simple
User=kanban
WorkingDirectory=/srv/apps/kanban/kanban-board
ExecStart=/srv/apps/kanban/kanban-board/target/release/kanban-board
Restart=always
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable kanban-board
sudo systemctl start kanban-board
Проверяем статус:
sudo systemctl status kanban-board
✅ Готово!
- ✅ Rust-бинарник
- ✅ Без Docker
- ✅ Без Node.js
- ✅ Красивый UI
- ✅ Перетаскивание задач
- ✅ Удаление задач
- ✅ Запуск как сервис
- ✅ Все файлы на месте
Теперь у вас есть красивая Kanban-доска, которую можно использовать для управления задачами, как часть домашнего сервера или отдельно.