Skip to content

TimeTrackr Stack: PostgreSQL 17 + SSH tunnel + Odoo sync

The TimeTrackr Stack is the data layer of the time-tracking system that feeds Odoo timesheets. It stands out from the other stacks by exposing no port to the Internet: only the desktop client, after opening a dedicated SSH tunnel, can reach the database.

ComponentRoleLocation
TimeTrackr.exeWindows desktop client (timer + entry input)User workstation
SSH tunnelEncrypted transport to the VPS, restricted to the PG portUser connection ↔ VPS
PostgreSQL 17Local storage of time entriesVPS (timetrackr-stack)
N8N webhooksSync to Odoo (projects read, entries write)VPS (n8n-stack)
OdooSource of projects and target of timesheetsVPS (odoo-stack)

Hostinger VPS

odoo-stack

n8n-stack

timetrackr-stack · timetrackr-internal

HTTPS · X-TimeTrackr-Token

HTTPS · X-TimeTrackr-Token

port 22 · SSH key

sshd · Match User timetrackr-tunnel

ForceCommand /bin/false

PermitOpen localhost:5433

PermitTTY no

TimeTrackr.exe · Windows · tray

SSH tunnel · timetrackr-tunnel@VPS:22

timetrackr-postgres · PG 17 · 127.0.0.1:5433

SSL/TLS · SCRAM-SHA-256

Webhook · /webhook/timetrackr-projects

Webhook · /webhook/timetrackr-entries

Data Table · timetrackr_user_mapping

project.project / project.task

account.analytic.line


ApproachProsCons
Public port 5432Simple, allowed IPs in firewallPermanent attack surface, brute force, scans, leak risk on misconfigured firewall
Dedicated SSH tunnelNo DB port on the Internet, SSH key auth, granular sshd restrictionsHeavier initial setup (key to deploy on the client)
VPN (WireGuard)Convenient for many servicesOverkill for a single DB port, IP/conf maintenance

The SSH tunnel wins for this case: a single user, a single port, and the timetrackr-tunnel user cannot do anything else than forward to localhost:5433 (no shell, no execution).

Why a local buffer rather than write straight into Odoo?

Section titled “Why a local buffer rather than write straight into Odoo?”

Why sync via N8N rather than a direct Odoo connection?

Section titled “Why sync via N8N rather than a direct Odoo connection?”
ChoiceBenefit
N8N webhooksMapping logic (TimeTrackr username → Odoo employee) lives outside the client, editable without redeploying the .exe
Header AuthRotatable token, independent of Odoo credentials
No XML-RPC in the clientThe client never sees Odoo credentials
Centralised auditAll timesheet creations are visible in N8N executions

A Windows desktop app that lives in the system tray. Main features:

  • Project/task timer: start / stop a timer attached to an Odoo (project, task) pair.
  • Dynamic project list: fetched on startup via /webhook/timetrackr-projects.
  • Batch sync: pushes accumulated entries via /webhook/timetrackr-entries.
  • Offline mode: keeps recording locally (PostgreSQL), retries the sync when the network returns.
# DB connection (via local SSH tunnel)
DB_HOST=127.0.0.1
DB_PORT=15433 # locally forwarded port (configurable)
DB_NAME=timetrackr_db
DB_USER=timetrackr_user
DB_PASSWORD=<from VPS .env>
DB_SSLMODE=require
# N8N webhooks (public HTTPS)
PROJECTS_URL=https://n8n.guigpap.com/webhook/timetrackr-projects
WEBHOOK_URL=https://n8n.guigpap.com/webhook/timetrackr-entries
WEBHOOK_TOKEN=<same token as the Header Auth credential in N8N>

The client (or an associated Windows service) opens the tunnel before reaching the database:

Fenêtre de terminal
ssh -N -L 15433:localhost:5433 timetrackr-tunnel@85.31.237.23 -i ~/.ssh/timetrackr_key
  • -N: no command, just the forward.
  • -L 15433:localhost:5433: local port 15433 forwards to localhost:5433 on the VPS.
  • The timetrackr-tunnel user is restricted to that single forward (see sshd block below).

The sshd_timetrackr-tunnel.conf file is deployed in /etc/ssh/sshd_config.d/:

Match User timetrackr-tunnel
AllowTcpForwarding yes
PermitOpen localhost:5433
ForceCommand /bin/false
PermitTTY no
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no
DirectiveEffect
ForceCommand /bin/falseThe user cannot obtain a shell
PermitOpen localhost:5433No other port forward allowed
PermitTTY noNo interactive terminal
X11Forwarding no / AllowAgentForwarding no / PermitTunnel noAll other forward types disabled

A client key is deployed via the helper script:

Fenêtre de terminal
./timetrackr-stack/setup.sh tunnel-user # creation + sshd reload (one-time)
./timetrackr-stack/deploy_tunnel_key.sh client.pub # add a client key
# timetrackr-stack/docker-compose.yaml (excerpt)
postgres:
image: postgres:17-alpine
container_name: timetrackr-postgres
environment:
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256"
TIMETRACKR_APP_USER: ${TIMETRACKR_APP_USER:-timetrackr_user}
TIMETRACKR_APP_PASSWORD: ${TIMETRACKR_APP_PASSWORD:?...}
volumes:
- timetrackr-db:/var/lib/postgresql/data
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
- ./pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
- ./init-ssl.sh:/usr/local/bin/init-ssl.sh:ro
- ./init-app-user.sh:/docker-entrypoint-initdb.d/10-init-app-user.sh:ro
- ./ssl:/etc/postgresql/ssl:ro
entrypoint: ["/bin/bash", "/usr/local/bin/init-ssl.sh"]
ports:
- "127.0.0.1:5433:5432" # NEVER 0.0.0.0
deploy:
resources:
limits:
memory: 512M
reservations: { memory: 128M }
LayerProtection
Network127.0.0.1:5433 only (no Internet exposure)
TransportMandatory SSL/TLS (hostnossl reject in pg_hba.conf), AEAD + ECDHE only
AuthenticationSCRAM-SHA-256 (no MD5, no plaintext)
AuthorisationApp user limited to SELECT/INSERT/UPDATE/DELETE on the public schema
QuotasApp user capped at 10 concurrent connections
Timeouts60 s per statement, 300 s in idle-in-transaction, TCP keepalives
Containerno-new-privileges:true, capped memory

The init-ssl.sh entrypoint dynamically picks how certificates are mounted:

ModeTriggerUse case
Pre-mounted./ssl/server.crt + server.key + ca.crt presentsslmode=verify-full on the client (production)
Persistent volumeA valid certificate already exists in the volumeRestart recovery
Self-signedNo cert availableOn-the-fly generation (10-year validity), sslmode=require

On first init, the app user is created with exactly what’s needed:

CREATE ROLE timetrackr_user WITH LOGIN PASSWORD '...';
ALTER ROLE timetrackr_user CONNECTION LIMIT 10;
GRANT CONNECT ON DATABASE timetrackr_db TO timetrackr_user;
GRANT USAGE, CREATE ON SCHEMA public TO timetrackr_user;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA public TO timetrackr_user;

The postgres admin user stays restricted to 127.0.0.1 via pg_hba.conf — accessible only from inside the container.

Two active workflows handle the round-trip on the N8N side:

WorkflowEndpointMethodRole
TimeTrackr - Projects/webhook/timetrackr-projectsGETList of Odoo projects/tasks for the client menus
TimeTrackr - Receive Entries/webhook/timetrackr-entriesPOSTCreates account.analytic.line records in Odoo

Both webhooks are protected by Header Auth (X-TimeTrackr-Token). They are not in the Caddy block list (unlike internal webhooks) — accessible from the client workstation over public HTTPS.

The TimeTrackr username → Odoo employee mapping is stored in the N8N Data Table timetrackr_user_mapping. No Odoo secret crosses the client, no DB connection from N8N → TimeTrackr.

See TimeTrackr → Odoo for the node breakdown, payload format, and validation rules.

Fenêtre de terminal
# Stack management
docker compose -f timetrackr-stack/docker-compose.yaml up -d
docker compose -f timetrackr-stack/docker-compose.yaml logs -f
# Verify SSL is on
docker exec timetrackr-postgres psql -U postgres -c "SHOW ssl;"
# Verify the port is bound only locally
ss -tlnp | grep 5433 # must show 127.0.0.1:5433, never 0.0.0.0
# Test the app user locally
docker exec timetrackr-postgres psql -U timetrackr_user -d timetrackr_db -c "SELECT 1;"
# Database size
docker exec timetrackr-postgres psql -U postgres -d timetrackr_db \
-c "SELECT pg_size_pretty(pg_database_size('timetrackr_db'));"
# Verify a tunnel client gets no shell
ssh timetrackr-tunnel@localhost # must close immediately

LimitImpactMitigation
Effectively single-userOnly one entry in timetrackr_user_mappingFine for my current use; add employees via the Data Table
Windows-only clientNo macOS / Linux / mobileTo consider if needed (the tunnel + webhook protocol stays portable)
Self-signed by defaultWarning on first client startupMount Let’s Encrypt certs in ./ssl/ for verify-full
No automatic re-sync on the N8N sideIf a POST fails on the Odoo side, the entry stays on the clientThe client retries; a reconciliation workflow remains to be written

If I want to onboard a second user:

  • Deploy a second key via deploy_tunnel_key.sh.
  • Add a row to timetrackr_user_mapping (username, Odoo employee_id).
  • The rest of the config (DB user, port forward) is shared.

If I want to replace the SSH tunnel:

  • WireGuard at wg.guigpap.com would expose a private subnet including Postgres. More convenient for multi-services (Postgres admin in addition to the single client), but higher operational cost (IP, MTU, peer config).

If I want to change the timesheet mapping:

  • Edit the N8N workflow TimeTrackr - Receive Entries directly (e.g. add an analytic category, filter some tasks).
  • No client deployment required — that’s the whole point of webhook-based sync.
MetricSourceAttention threshold
Database sizepg_database_size('timetrackr_db')Abnormal growth = revisit local retention on the client
Active connectionsSELECT count(*) FROM pg_stat_activityClose to 10 = app user limit reached
N8N sync failuresExecutions of the TimeTrackr - Receive Entries workflowSpike = check the token, Odoo state, mapping
timetrackr-tunnel SSH attemptsauth.logBrute force = revisit firewall-allowed IPs

  • Glossary — SCRAM-SHA-256, SSH tunnel, timesheet