Extract CreateRecurringTask into engine package for reuse by both CLI and API. Add POST /tasks/parse endpoint for CLI-style input parsing. Remove FK constraint on change_log to preserve history after task deletion. Update web frontend to filter completed tasks from view and add mock mode support for development. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
12 KiB
Opal Infrastructure Integration Plan
Context
This document describes how to integrate opal-task (Go API) and opal-web (SvelteKit PWA) into the existing your-infra Ansible infrastructure on the your-server homelab server.
Current your-infra Architecture
The VPS (your-server / 203.0.113.10) runs Arch Linux with:
- Caddy reverse proxy with auto-HTTPS (Let's Encrypt + Cloudflare DNS challenge)
- PostgreSQL and Valkey as native systemd services, exposed via Unix sockets only
- Podman with Quadlet for containerized services (Authentik, etc.)
- Authentik SSO at
auth.example.com - Services added as Ansible roles in
roles/<service>/ - Secrets managed via Ansible Vault (
vault.yml) - Caddy configs use sites-enabled pattern (
/etc/caddy/sites-enabled/*.caddy)
Opal Service Profile
| Property | opal-task | opal-web |
|---|---|---|
| Type | Go binary (REST API) | Static site (SvelteKit PWA) |
| Database | SQLite (embedded) | None (client-side localStorage) |
| External deps | None | None |
| Port | :8080 (configurable) |
N/A (static files) |
| Auth | OAuth2/JWT via Authentik | Passes tokens to API |
| Build output | Single binary | build/ directory (HTML/JS/CSS) |
Key observation: opal has zero external service dependencies — no PostgreSQL, no Valkey, no message queues. SQLite is embedded in the Go binary.
Recommended Deployment Architecture
opal.example.com (Caddy, auto-HTTPS)
├── /* → file_server /var/www/opal/ (static PWA)
├── /api/* → reverse_proxy :8080 (opal-task binary)
└── /auth/* → reverse_proxy :8080 (opal-task OAuth endpoints)
auth.example.com (Authentik, already running)
└── OAuth2/OIDC provider for opal
Decision: Native systemd service, not containerized
opal-task should run as a native systemd service, not in a Podman container.
Rationale:
- Single static binary with no runtime dependencies
- SQLite is embedded — no database socket mounts needed
- No benefit from container isolation (no network services to fence off, no complex filesystem needs)
- Simpler deployment: copy binary, restart service
- Matches patterns used by other native services in the infrastructure
- Containerizing would add Quadlet files, image builds, and volume mounts for zero gain
Decision: Static file serving for frontend
opal-web builds to static HTML/JS/CSS via adapter-static. Caddy serves these directly with file_server and SPA fallback. No Node.js runtime on the server.
Rationale:
- SvelteKit adapter-static produces plain files — no SSR, no server process
- Caddy already handles compression, caching headers, and HTTP/2
- Same pattern as other static frontend
- PWA service worker handles offline caching client-side
Decision: Keep SQLite, skip PostgreSQL
opal-task is a single-user personal task manager with device sync. SQLite is the correct database.
Rationale:
- Single writer (one API server process) — no write contention
- Backup is
sqlite3 .backupor justcpthe file - No connection pooling, no socket permissions, no user/role management
- Moving to PostgreSQL would require rewriting the data layer for no benefit
- If multi-user support is ever needed, this can be revisited
Ansible Role Design
Create roles/opal/ in your-infra:
roles/opal/
├── defaults/main.yml
├── meta/main.yml
├── tasks/
│ ├── main.yml
│ ├── user.yml
│ ├── backend.yml
│ ├── frontend.yml
│ └── caddy.yml
├── templates/
│ ├── opal.service.j2
│ ├── opal.env.j2
│ └── opal.caddy.j2
├── handlers/
│ └── main.yml
└── files/
└── (binary + static build copied here during deploy)
Role Dependencies
# meta/main.yml
dependencies:
- role: caddy
No dependency on postgresql or valkey — opal doesn't use them.
Default Variables
# defaults/main.yml
opal_domain: "opal.example.com"
opal_user: "opal"
opal_group: "opal"
# Paths
opal_binary_path: "/usr/local/bin/opal"
opal_data_dir: "/var/lib/opal"
opal_config_dir: "/etc/opal"
opal_web_root: "/var/www/opal"
opal_db_path: "{{ opal_data_dir }}/opal.db"
# Server
opal_server_addr: ":8080"
# OAuth (secrets from vault)
opal_oauth_enabled: true
opal_oauth_issuer: "https://auth.example.com/application/o/opal/"
opal_oauth_redirect_uri: "https://{{ opal_domain }}/auth/callback"
opal_oauth_client_id: "{{ vault_opal_oauth_client_id }}"
opal_oauth_client_secret: "{{ vault_opal_oauth_client_secret }}"
# JWT (secret from vault)
opal_jwt_secret: "{{ vault_opal_jwt_secret }}"
opal_jwt_expiry: 3600
opal_refresh_token_expiry: 604800
Vault Secrets
Add to host_vars/your-server/vault.yml:
vault_opal_oauth_client_id: "<from Authentik>"
vault_opal_oauth_client_secret: "<from Authentik>"
vault_opal_jwt_secret: "<openssl rand -hex 32>"
Task Breakdown
user.yml — Create dedicated system user:
- name: Create opal system user
ansible.builtin.user:
name: "{{ opal_user }}"
group: "{{ opal_group }}"
system: true
shell: /bin/false
home: "{{ opal_data_dir }}"
create_home: false
backend.yml — Deploy binary and systemd unit:
- Create directories (
/var/lib/opal,/etc/opal) - Copy pre-built binary to
/usr/local/bin/opal - Template environment file to
/etc/opal/opal.env - Template systemd unit to
/etc/systemd/system/opal.service - Enable and start the service
frontend.yml — Deploy static build:
- Create
/var/www/opal/ - Synchronize build output to web root
- Set ownership to
root:root(Caddy reads as root)
caddy.yml — Deploy reverse proxy config:
- Template Caddy site config to
/etc/caddy/sites-enabled/opal.caddy - Notify Caddy reload handler
systemd Unit Template
# opal.service.j2
[Unit]
Description=Opal Task API Server
After=network.target
[Service]
Type=simple
User={{ opal_user }}
Group={{ opal_group }}
WorkingDirectory={{ opal_data_dir }}
EnvironmentFile={{ opal_config_dir }}/opal.env
ExecStart={{ opal_binary_path }} server start --addr {{ opal_server_addr }}
Restart=always
RestartSec=5
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={{ opal_data_dir }}
ReadOnlyPaths={{ opal_config_dir }}
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
Caddy Site Template
# opal.caddy.j2
{{ opal_domain }} {
root * {{ opal_web_root }}
# API and auth endpoints → Go backend
handle /api/* {
uri strip_prefix /api
reverse_proxy localhost{{ opal_server_addr }}
}
handle /auth/* {
reverse_proxy localhost{{ opal_server_addr }}
}
# Static PWA with SPA fallback
handle {
try_files {path} /index.html
file_server
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://auth.example.com;"
-Server
}
log {
output file /var/log/caddy/opal.log
format json
}
encode gzip zstd
}
Environment File Template
# opal.env.j2
# Server
SERVER_ADDR={{ opal_server_addr }}
OPAL_CONFIG_DIR={{ opal_config_dir }}
OPAL_DATA_DIR={{ opal_data_dir }}
# OAuth
OAUTH_ENABLED={{ opal_oauth_enabled | lower }}
OAUTH_ISSUER={{ opal_oauth_issuer }}
OAUTH_CLIENT_ID={{ opal_oauth_client_id }}
OAUTH_CLIENT_SECRET={{ opal_oauth_client_secret }}
OAUTH_REDIRECT_URI={{ opal_oauth_redirect_uri }}
# JWT
JWT_SECRET={{ opal_jwt_secret }}
JWT_EXPIRY={{ opal_jwt_expiry }}
REFRESH_TOKEN_EXPIRY={{ opal_refresh_token_expiry }}
Build & Deploy Strategy
Option A: Local build + Ansible deploy (recommended for now)
Build on the dev machine, deploy with Ansible:
# Build backend (cross-compile for linux/amd64)
cd opal-task
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o opal main.go
# Build frontend
cd opal-web
npm run build
# Deploy
ansible-playbook playbooks/deploy-opal.yml --ask-vault-pass
The Ansible playbook copies the pre-built binary and static files to the server. This keeps the server clean (no Go toolchain, no Node.js).
Note on CGO: opal-task uses mattn/go-sqlite3 which requires CGO. Cross-compiling with CGO_ENABLED=1 needs a C cross-compiler (x86_64-linux-gnu-gcc or equivalent). If building on the same architecture as the server, this just works. For true cross-compilation, consider using zig cc as the C compiler or building in a Docker container matching the target.
Option B: GitHub Actions CI/CD (future)
Follow the existing CI/CD pattern already in your-infra:
- Push to
maintriggers workflow - Build Go binary in a Linux container (solves CGO cross-compilation)
- Build SvelteKit static site
- SSH to your-server, copy artifacts, restart service
# .github/workflows/deploy.yml (sketch)
on:
push:
branches: [main]
paths:
- 'opal-task/**'
- 'opal-web/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build backend
run: |
cd opal-task
go build -o opal main.go
- name: Build frontend
run: |
cd opal-web
npm ci
npm run build
- name: Deploy
run: |
scp opal-task/opal ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/opal
rsync -avz --delete opal-web/build/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/opal/
ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"sudo cp /tmp/opal /usr/local/bin/ && sudo systemctl restart opal"
Backup Strategy
SQLite backup is trivial — one file, atomic snapshots:
# Daily backup via cron or systemd timer
sqlite3 /var/lib/opal/opal.db ".backup /var/backups/opal/opal-$(date +%Y%m%d).db"
# Retention: keep 14 days
find /var/backups/opal/ -name "opal-*.db" -mtime +14 -delete
This can be added as a systemd timer in the Ansible role, or as a cron entry.
Monitoring
opal-task exposes GET /health with no authentication. Add this to the existing metrics stack:
- Caddy access logs in JSON at
/var/log/caddy/opal.log - systemd journal for opal service (
journalctl -u opal) - Health check polling from VictoriaMetrics or a simple uptime probe
Playbook Integration
Add to your-infra.yml (homelab playbook):
- hosts: your-server
roles:
# ... existing roles ...
- role: opal
tags: [opal]
Deploy independently:
ansible-playbook your-infra.yml --tags opal --ask-vault-pass
Or create a dedicated playbooks/deploy-opal.yml for quick redeploys.
Checklist
- Create Authentik OAuth application for opal (see
docs/authentik-setup.md) - Add vault secrets to
host_vars/your-server/vault.yml - Create
roles/opal/in your-infra - Point DNS
opal.example.comto your-server IP - Build and deploy backend binary
- Build and deploy frontend static files
- Verify health check:
curl https://opal.example.com/api/health - Test OAuth login flow end-to-end
- Set up SQLite backup timer
- (Optional) Add GitHub Actions CI/CD workflow