# [[Deploying Tasks]]
![[Deploying Tasks.svg]]
**Architecture: Obsidian → Syncthing → DigitalOcean → Docker → Traefik → Tasks.md**
This guide documents the full process I used to deploy a **public web-accessible Tasks.md Kanban board**, backed by Markdown in my Obsidian vault, with a **read-only public view** (server auto-reverts local changes).
The final URL: **https://kanban.nvdh.dev**
---
## 1. Goals
- Serve a Tasks.md Kanban board **publicly** on the internet
- Source data from **Markdown files in my Obsidian vault**
- Use **Syncthing** to sync files from my laptop → server
- Use **Docker** to run Tasks.md
- Use **Traefik v3** for HTTPS + reverse proxy
- Site should appear **read-only** to visitors (web edits auto-revert)
---
## 2. High-Level Architecture
```
[Obsidian Vault] <-- authoritative Markdown
|
| (Syncthing — Send Only)
v
[DigitalOcean Droplet]
|
| /srv/tasksmd/tasks_public (Syncthing — Receive Only)
|
v
[Docker Container: tasksmd] <-- renders the Kanban board
|
v
[Traefik v3] <-- HTTPS, routes kanban.nvdh.dev
|
https://kanban.nvdh.dev
```
---
## 3. Server Preparation (DigitalOcean)
## 3.1 Create droplet
- Ubuntu LTS recommended
- Install Docker + Docker Compose:
```bash
sudo apt update
sudo apt install -y docker.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Logout/login again.
---
## 4. Install Syncthing on the Server
### 4.1 Install
```bash
sudo apt install -y syncthing
```
### 4.2 Run Syncthing as a user service
```bash
systemctl --user enable syncthing
systemctl --user start syncthing
```
### 4.3 Make Web UI reachable
Edit Syncthing config (`~/.config/syncthing/config.xml`):
```xml
<gui enabled="true" tls="true">
<address>0.0.0.0:8384</address>
</gui>
```
Restart:
```bash
systemctl --user restart syncthing
```
### 4.4 Pair laptop ↔ server
Add devices in Syncthing UI.
### 4.5 Configure the Tasks folder
Create server folder:
```bash
sudo mkdir -p /srv/tasksmd/tasks_public
sudo chown -R $USER:$USER /srv/tasksmd
```
Add this folder to Syncthing on the server.
#### Set correct direction:
- **Laptop**: *Send Only*
- **Server**: *Receive Only*
This ensures the server **never pushes changes back** to the vault.
---
## 5. Domain + DNS
Create subdomain:
```
kanban.nvdh.dev → A record → <droplet IP>
```
No CNAME needed.
---
## 6. Traefik Setup (Reverse Proxy + HTTPS)
### 6.1 File structure
```
/srv/apps/tasksmd/
docker-compose.yml
/srv/traefik/
letsencrypt/
dynamic/
tasksmd.yml
```
Make directories:
```bash
sudo mkdir -p /srv/traefik/letsencrypt
sudo mkdir -p /srv/traefik/dynamic
sudo mkdir -p /srv/apps/tasksmd
```
---
## 7. Docker Compose Configuration
Create:
```
/srv/apps/tasksmd/docker-compose.yml
```
```yaml
version: "3.9"
services:
traefik:
image: traefik:v3.1
container_name: traefik
restart: unless-stopped
command:
- "--api.dashboard=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--providers.docker=false"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=nicole@nicolevanderhoeven.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--log.level=DEBUG"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
volumes:
- /srv/traefik/letsencrypt:/letsencrypt
- /srv/traefik/dynamic:/etc/traefik/dynamic:ro
tasksmd:
image: baldissaramatheus/tasks.md
container_name: tasksmd
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /srv/tasksmd/tasks_public:/tasks # read–write so app can run properly
- /srv/tasksmd/config:/config
```
---
## 8. Traefik Dynamic Routing
```
/srv/traefik/dynamic/tasksmd.yml
```
```yaml
http:
routers:
tasksmd:
rule: "Host(`kanban.nvdh.dev`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: tasksmd
services:
tasksmd:
loadBalancer:
servers:
- url: "http://tasksmd:8080"
```
This is the rule that fixed all the 502 issues.
---
## 9. Start Everything
```bash
cd /srv/apps/tasksmd
docker compose up -d
```
Verify:
```bash
docker ps
curl -vk https://kanban.nvdh.dev/
```
You should get **HTTP/2 200** and Tasks.md’s HTML.
---
## 10. Read-Only Behavior: Protecting the Vault
Because:
- Laptop = **Send Only**
- Server = **Receive Only**
→ My Obsidian vault is fully protected.
Edits from the web UI **never sync back** to the vault.
But Tasks.md **does** write to files on the server (it must).
So we made the public board **functionally** read-only by automatically reverting local changes.
---
## 11. Automatic Revert of Local Changes (Every 5 Minutes)
### 11.1 Get Syncthing API key + Folder ID
From Syncthing UI → **Actions → Settings → GUI**
Copy API Key.
Find the folder → note **Folder ID**.
---
### 11.2 Create auto-revert script
```
sudo nano /usr/local/bin/syncthing-revert-tasks.sh
```
Paste:
```bash
#!/usr/bin/env bash
API_KEY="YOUR_API_KEY_HERE"
FOLDER_ID="YOUR_FOLDER_ID_HERE"
ST_URL="https://127.0.0.1:8384"
curl -s -k -X POST \
-H "X-API-Key: $API_KEY" \
"$ST_URL/rest/db/revert?folder=$FOLDER_ID"
```
Make it executable:
```bash
sudo chmod +x /usr/local/bin/syncthing-revert-tasks.sh
```
---
### 11.3 Cron job (every 5 minutes)
```bash
crontab -e
```
Add:
```cron
*/5 * * * * /usr/local/bin/syncthing-revert-tasks.sh >/tmp/syncthing-revert.log 2>&1
```
This ensures:
- Anyone modifying the board via the web
- **Has their changes wiped every few minutes**
- Vault always wins
- Board appears read-only
---
## 12. Result
- **https://kanban.nvdh.dev** is live with HTTPS
- Kanban loads from my Obsidian Markdown
- Publicly viewable
- Edits in Obsidian → synced to server
- Edits on server → auto-deleted every few minutes
- Vault is never polluted
- Tasks.md runs reliably with full write access
- Users experience a “read-only” board
---
## 13. Future Enhancements (Optional)
- Add Basic Auth to Traefik for semi-private access
- Add a second private editing instance
- Auto-deploy from Git instead of Syncthing
- Apply custom CSS in `stylesheets/custom.css`
- Use systemd timers instead of cron
---
## 14. Quick Troubleshooting
#### “502 Bad Gateway”
- Check dynamic config:
Must be: `url: "http://tasksmd:8080"`
- Ensure both containers share same Docker network
- Confirm backend works:
`docker exec -it traefik curl http://tasksmd:8080/`
#### HTTPS errors
- Check Traefik ACME storage: `/srv/traefik/letsencrypt/acme.json`
- Confirm DNS points to droplet
---
## 15. Final Notes
Deploying this stack required:
- Docker
- Traefik v3 file provider
- Syncthing direction control
- Debugging DNS inside containers
- Fixing Traefik 502s
- Handling HTTPS + HSTS
- Understanding how Tasks.md writes to disk
It now runs solidly and automatically.
This is effectively a **self-hosted read-only Obsidian Publish** for a Kanban board.
%%
# Excalidraw Data
## Text Elements
## Drawing
```json
{
"type": "excalidraw",
"version": 2,
"source": "https://github.com/zsviczian/obsidian-excalidraw-plugin/releases/tag/2.1.4",
"elements": [
{
"id": "4y8R7iOA",
"type": "text",
"x": 118.49495565891266,
"y": -333.44393157958984,
"width": 3.8599853515625,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"roundness": null,
"seed": 967149026,
"version": 2,
"versionNonce": 939059582,
"isDeleted": true,
"boundElements": null,
"updated": 1713723615080,
"link": null,
"locked": false,
"text": "",
"rawText": "",
"fontSize": 20,
"fontFamily": 4,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "",
"lineHeight": 1.2
}
],
"appState": {
"theme": "dark",
"viewBackgroundColor": "#ffffff",
"currentItemStrokeColor": "#1e1e1e",
"currentItemBackgroundColor": "transparent",
"currentItemFillStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeStyle": "solid",
"currentItemRoughness": 1,
"currentItemOpacity": 100,
"currentItemFontFamily": 4,
"currentItemFontSize": 20,
"currentItemTextAlign": "left",
"currentItemStartArrowhead": null,
"currentItemEndArrowhead": "arrow",
"scrollX": 583.2388916015625,
"scrollY": 573.6323852539062,
"zoom": {
"value": 1
},
"currentItemRoundness": "round",
"gridSize": null,
"gridColor": {
"Bold": "#C9C9C9FF",
"Regular": "#EDEDEDFF"
},
"currentStrokeOptions": null,
"previousGridSize": null,
"frameRendering": {
"enabled": true,
"clip": true,
"name": true,
"outline": true
}
},
"files": {}
}
```
%%