# [[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": {} } ``` %%