This is our fork of solidtime, a self-hosted open-source time tracker. We're running it as our own instance instead of the paid Clockify/Toggl-style tools — full control over the code, no per-seat pricing, deployed on our own DigitalOcean droplet via Kamal.
Upstream's own README (features list, upstream contributing/security docs) is still relevant and linked at the bottom — this file only covers what's specific to how we run it.
- URL: https://138.197.0.49.sslip.io
(sslip.io auto-resolves
<ip>.sslip.ioto that IP — no registrar/DNS setup needed. Swap for a real domain later by changingproxy.hosts+APP_URLinconfig/deploy.production.yml, one value each.) - Server: DigitalOcean droplet
138.197.0.49("Time Tracker" project), 1 vCPU / ~1GB RAM. - Admin panel:
/admin, gated by theSUPER_ADMINSenv var (comma-separated emails).
Deploys run through Kamal (same tool as the gnosis repo).
bin/kamal deploy -d productionThis also happens automatically via .github/workflows/deploy.yml on every push to main —
secrets live in this repo's GitHub Actions secrets (APP_KEY, PASSPORT_PRIVATE_KEY,
PASSPORT_PUBLIC_KEY, DB_PASSWORD, MAIL_USERNAME/MAIL_PASSWORD once real SMTP creds
exist, DEPLOY_SSH_KEY — a dedicated deploy key, not anyone's personal key).
We use Kamal's local registry feature (registry.server: localhost:5555 in
config/deploy.yml) instead of a cloud registry. Kamal spins up a throwaway registry
container on whichever machine runs the deploy and tunnels the image to the server over SSH.
This sidesteps needing any registry account/subscription at all — no DO Container Registry,
no Docker Hub private repo.
Three roles, all built from the same image, differentiated by CONTAINER_MODE:
| Role | CONTAINER_MODE | Does |
|---|---|---|
web |
http |
Serves the app (FrankenPHP/Octane), behind kamal-proxy |
queue |
worker |
Runs php artisan queue:work |
scheduler |
scheduler |
Runs the Laravel scheduler (cron-equivalent) |
Plus one accessory: database (Postgres 15). We deliberately don't run the gotenberg
accessory (PDF export/invoicing) — it's the heaviest thing in the stack (headless Chromium)
and we don't use invoicing, so it's not worth the RAM on a small droplet. If that changes,
add it back to config/deploy.yml's accessories: and deploy.production.yml's hosts.
Upstream's docker/prod/Dockerfile only actually builds inside their own CI
(build-public.yml), which pre-runs composer install + npm run build on the GitHub
Actions runner before calling docker build — the final COPY . . just picks up an
already-built tree. A plain docker build from a fresh checkout never worked standalone.
Fixed so it's fully self-contained (see commit history on docker/prod/Dockerfile):
- Uncommented and wired up the
common/build/runnermulti-stage build that was present-but-disabled in the file. - Swapped the
bunbuild stage fornpm— this repo shipspackage-lock.json, notbun.lock. - Dropped
NODE_ENV=productionbeforenpm ci— it was silently skippingdevDependencies(wherevitelives), breaking the frontend build. - Dropped the custom
xcaddy-compiled FrankenPHP binary (only existed to add thecaddy-cbrotlicompression module) — the stockdunglas/frankenphpimage already has a working binary, and compiling Go/Caddy from source is slow for a compression-codec nice-to-have. - Dropped
composer install --audit(fails the build on unrelated known CVE advisories in dependencies — not something a build step should gate on) and a stray leftoverRUN cat .envdebug line. HEALTHCHECK NONE, explicit. Just omitting our ownHEALTHCHECKwasn't enough — thedunglas/frankenphpbase image bakes in its own (curl http://localhost:2019/metrics, Caddy's admin API). That's fine for thewebrole, butworker/schedulercontainers never run Caddy/FrankenPHP at all — port 2019 never listens there, so the inherited healthcheck sits instartingforever and Kamal's readiness poller (which checksdocker inspect ... State.Healthdirectly for non-proxied roles) kills a container that actually booted fine.
Deploy config additions beyond a stock Kamal setup:
AUTO_DB_MIGRATE: "true"— runsphp artisan migrate --isolated --forceon every boot (--isolatedmeans only one container actually wins the lock; the rest skip).TRUSTED_PROXIES— needed since kamal-proxy sits in front; without it Laravel can't tell a request came in over HTTPS (/health-check/debug'ssecure/is_trusted_proxyreadfalseotherwise).proxy.ssl: true+proxy.forward_headers: true—ssldefaults tofalsein Kamal (fine forgnosis, which sits behind a DO load balancer that terminates TLS; we have no such load balancer here, so kamal-proxy itself has to do it via Let's Encrypt). Also,forward_headersdefaults tofalsespecifically whenssl: true— the opposite of what's needed — so it has to be set explicitly too.
- No real SMTP yet —
MAIL_USERNAME/MAIL_PASSWORDsecrets are unset, so password resets/invitations won't actually send email until real creds are added. - No automated Postgres backups — data only lives in the
solidtime_db_dataDocker volume on the droplet. - Small droplet — 1 vCPU / ~1GB RAM running
web+queue+scheduler+ Postgres. Fine for personal/small-team use; watch memory if usage grows. - sslip.io domain — works, but a real domain would be worth it if this becomes more than a personal tool.
- Feature list, self-hosting guides, contributing, license — the original project this is forked from.
- solidtime self-hosting docs
- solidtime CLI commands (
admin:user:create,admin:user:verify)
This fork is licensed the same as upstream: GNU Affero General Public License v3.0 (AGPL v3), see LICENSE.md.