Can I self-host Clockify?
YES, BUT · ONE WEEKEND— setup effort 3 of 4YES, BUT — it's called solidtime. It takes one prompt, a 2048 MB VPS, and about 240 minutes. That is $14.97 a month you stop paying Clockify — $179.64 a year on the Basic plan, 3 seats assumed.
Why people pay for Clockify
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Clockify gives the timer away and charges for the paperwork around it. Tracking is free for a small team, and then the things a business actually needs, adding time on somebody else's behalf, hiding pages from regular users, historical billable rates, approvals, invoicing and audit trails, arrive one tier at a time and are billed for every active or invited person on the Team page. What a company is really renting is the administrative layer plus the certainty that the hours are somewhere other than a spreadsheet.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | The pricing page prices this at $0 "For up to 5 users" and lists unlimited tracking, timesheet, calendar, mobile and desktop apps, team activity and reports. |
| Basicthe plan this page prices against | $4.99/mo per seat | $3.99 per seat per month billed annually, or 4.99 if billed monthly. This is the tier that adds billable rates, historical rates, adding time for other people, bulk edit, required fields and export and share. |
| Standard | $6.99/mo per seat | $5.49 per seat per month billed annually, or 6.99 if billed monthly. Adds time off, invoicing, approval, lock time, and attendance and overtime. |
| Pro | $9.99/mo per seat | $7.99 per seat per month billed annually, or 9.99 if billed monthly. Adds scheduling, forecasting, expenses, labor cost and profit, and budgets and estimates. |
| Enterprise | $14.99/mo per seat | $11.99 per seat per month billed annually, or 14.99 if billed monthly. Adds single sign-on, a custom subdomain, SCIM provisioning, account control and an audit log. |
| CAKE.com Bundle | $15.99/mo per seat | $12.99 per seat per month billed annually, or 15.99 if billed monthly. Bundles Enterprise Clockify with Pumble and Plaky, the other two products of the same parent. |
Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: high
Replaced by solidtime
One project, named before the prompt, so you know what you are about to install.
A modern time tracker with organisations, members, clients and billable rates, on a box that never counts seats.
The closest thing to Clockify's shape that you can run yourself: a modern tracker interface, organisations with members and roles, clients, projects and tasks, and billable rates set per organisation, per project or per member. Nothing in the self-hosted build is metered, because the open-source billing contract answers yes to every subscription check, so rounding, exports and more than one member are all available. Be clear-eyed about the age: the project's first tag is June 2024, this is a 0.x line shipping roughly two releases a month, and it is four containers rather than one. Reports export as CSV, XLSX and ODS; PDF needs a fifth container this install does not run.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- solidtimeour pickONE WEEKEND— setup effort 3 of 4
A modern time tracker with organisations, members, clients and billable rates, on a box that never counts seats.
The closest thing to Clockify's shape that you can run yourself: a modern tracker interface, organisations with members and roles, clients, projects and tasks, and billable rates set per organisation, per project or per member. Nothing in the self-hosted build is metered, because the open-source billing contract answers yes to every subscription check, so rounding, exports and more than one member are all available. Be clear-eyed about the age: the project's first tag is June 2024, this is a 0.x line shipping roughly two releases a month, and it is four containers rather than one. Reports export as CSV, XLSX and ODS; PDF needs a fifth container this install does not run.
- KimaiONE EVENING— setup effort 2 of 4
Timesheets with customers, projects, hourly rates and invoices, on a server that never asks how many seats you have.
The decade-old answer, and it holds its own page in this catalogue as the Toggl Track replacement. Kimai has a decade of invoicing edge cases behind it, ships invoices and exports out of the box, and runs in two containers instead of four, so if billing the hours is the whole point it is the safer bet. solidtime ranks first here because this page is about Clockify's shape specifically: the modern timer interface, the organisation-and-member model with roles, and rates attached to members rather than only to projects. Kimai's interface is older and its member model is thinner, and that is exactly the trade.
The swap
You'd run
solidtime
ONE WEEKEND · ~240 min to running · 2048 MB RAM
Clockify Basic · 3 seats assumed · vendor list price · checked 2026-08-14 · source
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~240 min3–24 hours, through the first backup
The prompt
Two paths to the same solidtime: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
381 lines · 16,489 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install solidtime 0.19.1 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. `<DOMAIN>` becomes `APP_URL`, and solidtime rejects every request whose Host is
neither it nor a subdomain of it, so its A record has to point here already. `<ADMIN_EMAIL>`
goes on the first account and into `SUPER_ADMINS`; no mail is configured, so it identifies an
account, not a mailbox.
solidtime needs 2048 MB of RAM available and 10 GB free on /srv: three PHP containers on Laravel
Octane plus a PostgreSQL. Both images have amd64 and arm64. Measure all four:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 2048 MB or free disk is under 10 GB, print both and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a
name that does not resolve, and failed attempts hit a rate limit.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/solidtime /srv/solidtime/backups
sudo install -d -m 700 /srv/solidtime/postgres
sudo install -d -m 750 -o 1000 -g 1000 /srv/solidtime/storage
ls -la /srv/solidtime
```
Assert: `backups` owned by the login user, `postgres` at mode `700` owned by root, `storage`
owned by uid `1000`. Leave `postgres` alone, the database image chowns it on first start. The
`1000` is the uid the image runs as, and root-owned `storage` cannot be written.
## 3. Secrets
Four secrets end up here. Three are written now: `APP_KEY` for session cookies, the Passport
private key that signs API tokens, and the PostgreSQL password. The image mints the first two
with a command upstream documents, so only the third comes from `openssl`; step 7 has the
application generate the fourth. Print none of them and keep them out of every log. The redirect
on the first line is what keeps the minted keys off the terminal.
```bash
umask 077
docker run --rm solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd php artisan self-host:generate-keys > /srv/solidtime/.env
grep -c -E '^(APP_KEY|PASSPORT_PRIVATE_KEY|PASSPORT_PUBLIC_KEY)=' /srv/solidtime/.env
cat >> /srv/solidtime/.env <<EOF
APP_ENV="production"
APP_DEBUG="false"
APP_URL="https://<DOMAIN>"
APP_FORCE_HTTPS="true"
APP_ENABLE_REGISTRATION="false"
TRUSTED_PROXIES="172.16.0.0/12,192.168.0.0/16,10.0.0.0/8"
SUPER_ADMINS="<ADMIN_EMAIL>"
LOG_CHANNEL="stderr"
LOG_LEVEL="info"
DB_CONNECTION="pgsql"
DB_HOST="postgres"
DB_DATABASE="solidtime"
DB_USERNAME="solidtime"
DB_PASSWORD="$(openssl rand -hex 32)"
QUEUE_CONNECTION="database"
MAIL_MAILER="log"
SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE="false"
SCHEDULING_TASK_SELF_HOSTING_TELEMETRY="false"
EOF
chmod 600 /srv/solidtime/.env
umask 022
ls -l /srv/solidtime/.env
```
Assert both and print both: `grep -c` prints `3`, and the file is mode `-rw-------`. Anything
but `3` means the image wrote something unexpected into the file, so delete it and run the block
again rather than editing around it.
Three lines are decisions. `QUEUE_CONNECTION` matters because Laravel defaults to `sync` and the
queue container exists to drain that table. `MAIL_MAILER="log"` puts invitations and reset links
in the container log instead of throwing, since no SMTP is configured. The last two ship on
upstream and are off here: both post this instance's URL to app.solidtime.io twice a day, and
telemetry adds counts of the users, organisations, projects and time entries in the database.
Tell the user those two lines turn either back on.
## 4. compose.yml
```bash
cat > /srv/solidtime/compose.yml <<'EOF'
# solidtime · the deterministic fallback. Authored by caniselfhostit from the
# upstream self-hosting documentation and the packaging at the pinned tag:
# docker guide ... https://docs.solidtime.io/self-hosting/guides/docker
# configuration .. https://docs.solidtime.io/self-hosting/configuration
# image build .... https://github.com/solidtime-io/solidtime/blob/v0.19.1/docker/prod/Dockerfile
#
# Four services, three of them the same image: CONTAINER_MODE picks HTTP,
# scheduler or queue worker, and there is no combined mode. Upstream's example
# ships a fifth, Gotenberg, only so reports export as PDF. Upstream pins
# postgres:15; their test matrix runs 15, 16 and 17, so this takes the newest.
# Digests read 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-solidtime: &solidtime
image: solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd
restart: unless-stopped
# The image copies the application in as uid 1000 and runs as it.
user: "1000:1000"
env_file: /srv/solidtime/.env
volumes:
# framework cache, shared; then the half worth keeping: exports/imports.
- solidtime-storage:/var/www/html/storage
- /srv/solidtime/storage:/var/www/html/storage/app
depends_on:
postgres:
condition: service_healthy
services:
postgres:
image: postgres:17.11-alpine@sha256:5d61573b31c206ae538c85893edeb6e320e1a9ffd838c0f9dca927fb6f765fa4
container_name: solidtime-db
restart: unless-stopped
environment:
POSTGRES_DB: solidtime
POSTGRES_USER: solidtime
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/solidtime/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U solidtime -d solidtime"]
interval: 10s
retries: 12
# No `ports:`: 5432 is reachable only from the other containers.
solidtime:
<<: *solidtime
container_name: solidtime
environment:
CONTAINER_MODE: http
ports:
# Loopback only: the host's Caddy is all that reaches 8184.
- "127.0.0.1:8184:8000"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/health-check/up"]
start_period: 60s
interval: 15s
retries: 10
scheduler:
<<: *solidtime
environment:
CONTAINER_MODE: scheduler
healthcheck:
# Ships with the image: asks supervisord if its process still runs.
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
queue:
<<: *solidtime
environment:
CONTAINER_MODE: worker
# Worker mode refuses to start without this.
WORKER_COMMAND: "php /var/www/html/artisan queue:work"
healthcheck:
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
volumes:
solidtime-storage:
EOF
cd /srv/solidtime && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Four services, one published port. `x-solidtime` is a
compose extension field the three application services merge in, so the pin is written once. Do
not delete `queue`: every saved time entry queues a job recalculating spent time on its project
and task, so without a worker the install looks fine and reports totals that never move.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy it first: a syntax error takes down every other site here.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-solidtime
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# solidtime · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.solidtime.io/self-hosting/configuration and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# APP_URL in .env, and the app rejects any Host that is neither it nor a
# subdomain of it.
<DOMAIN> {
# APP_ENABLE_REGISTRATION is already false in the app; this also takes the
# signup form off the hostname.
respond /register 404
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy: written untested it blanks a single-page app.
# No `encode`: the image runs Octane on FrankenPHP, whose own Caddy already
# compresses. 8184 is the loopback port compose publishes here, not a
# container port, and not open in the firewall.
reverse_proxy 127.0.0.1:8184
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-solidtime, reload, and report what it objected to.
## 6. Firewall
Two ports open, both Caddy's, idempotent on a Prompt Zero box:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8184 stays closed because compose binds it to 127.0.0.1, 5432 because compose never
publishes it. Assert: `ufw status verbose` prints `Status: active` and those three rules, with
none for 8184 or 5432.
## 7. Start and verify
The health endpoint answers before the schema exists, because upstream wrote it to touch neither
the database nor the cache, and sessions live in a database table, so the login page is a 500
until the migration has run.
```bash
cd /srv/solidtime
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health-check/up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health-check/up; echo
docker compose exec -T solidtime php artisan migrate --force
curl -sS https://<DOMAIN>/login | grep -o '<title inertia>[^<]*</title>'
```
Assert all four and print what you received: the loop ends on `200`, the health body is
`{"success":true}`, the migration prints migrations each ending `DONE` with no exception, and
the title prints `<title inertia>solidtime</title>`. If any misses, stop, run
`docker compose logs --tail 40 solidtime`, and name the likely earlier step: `502` is Caddy
reaching nothing on 8184, `SQLSTATE[08006]` is a container that never got step 3's password, and
a `403` on every request means `<DOMAIN>` and `APP_URL` differ. A running container is not
success.
Now create the only account this instance starts with. Registration is off, so there is no
signup form to race: accounts are made on the command line. The command generates a password and
prints it, so the output goes to a file. Ask the user for a display name first if they
want one other than their address.
```bash
umask 077
docker compose exec -T solidtime php artisan admin:user:create "<ADMIN_EMAIL>" "<ADMIN_EMAIL>" --verify-email > /srv/solidtime/first-account.txt
chmod 600 /srv/solidtime/first-account.txt
umask 022
grep -c '^Password: ' /srv/solidtime/first-account.txt
```
Assert: the count prints `1`. `--verify-email` marks the address verified without sending
anything, which is what makes the account usable with no mail.
STOP: tell the user to read their password with
`sudo grep '^Password: ' /srv/solidtime/first-account.txt`, put it in their password manager,
sign in at https://<DOMAIN> as `<ADMIN_EMAIL>`, and confirm the dashboard shows a `This Week`
card. Do not continue until they confirm. The next block destroys that file, so their password
manager has to hold it first.
Once they confirm, close the front door and prove it is closed:
```bash
cd /srv/solidtime
shred -u /srv/solidtime/first-account.txt
docker compose exec -T solidtime printenv APP_ENABLE_REGISTRATION
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/users/me
```
Assert all four and print each value. `shred -u` exits 0, taking the generated password off the
box. `printenv` prints `false`, the application's own answer: its sign-up action refuses to
create a user while that is off. `/register` prints `404`, because step 5 took the form off the
hostname, so both layers agree. The API call prints `401`, the assert that nothing reads time
entries without a token. Anything else stops the install: a `200` on `/register` means step 5
did not land, and `true` from `printenv` needs `docker compose up -d --force-recreate`.
## 8. First backup and restore
Two artifacts: a dump of the database that holds every organisation, project, client, rate and
time entry, and an archive of compose.yml, .env, the live Caddy site block and `storage`.
```bash
cd /srv/solidtime
docker compose exec -T postgres pg_dump -U solidtime -d solidtime | gzip > /srv/solidtime/backups/solidtime-db-$(date +%F).sql.gz
sudo tar -czf /srv/solidtime/backups/solidtime-files-$(date +%F).tar.gz -C /srv/solidtime compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/solidtime/backups/
```
Assert: both exist and both are non-empty. Print both sizes. Nothing goes offline: `pg_dump`
snapshots a running database consistently. A backup on the same disk is not a backup, so run
this on the user's machine:
```bash
mkdir -p ~/backups/solidtime
scp vps:/srv/solidtime/backups/* ~/backups/solidtime/
```
To restore, in this order. Untar the file archive into /srv/solidtime first, so `.env` is back
before any container starts: PostgreSQL takes its password from it the moment it initialises an
empty data directory. Then `docker compose down`, `sudo rm -rf /srv/solidtime/postgres`,
recreate it as in step 2, `docker compose up -d postgres`, wait for healthy, then
`gunzip -c /srv/solidtime/backups/solidtime-db-<date>.sql.gz | docker compose exec -T postgres psql -U solidtime -d solidtime`,
then `docker compose up -d` and re-run step 7's checks. A database restored without that `.env`
signs everyone out for good, because `APP_KEY` is in it.
## 9. Updating later
New versions are at https://github.com/solidtime-io/solidtime/releases. Say the cadence to the
user, it is the load-bearing fact here: the first tag was June 2024, this is still a 0.x
line, and it has shipped roughly two releases a month through 2026, with 0.19.1 landing on 7
August 2026. Read the notes rather than skimming the number, back up, then edit the one
`image:` line in `x-solidtime` to the new tag and digest:
```bash
cd /srv/solidtime
docker compose pull
docker compose up -d
docker compose exec -T solidtime php artisan migrate --force
docker compose logs --tail 30 solidtime
```
The migration is separate on purpose: upstream's `AUTO_DB_MIGRATE` variable runs it at container
start, and leaving that unset means an image pull can never rewrite the schema before there is a
dump of the old one. Re-run step 7's checks before calling it done.
## 10. What will probably go wrong
The first thing I did after `docker compose up -d` was load the site, get a 500, and start
pulling the compose file apart. Nothing was broken. The health endpoint answers `200` from the
moment Octane is listening, because upstream built it to touch neither the database nor the
cache, and sessions live in a table that does not exist until `php artisan migrate` has run, so
for a minute or two the box looks healthy and every page fails. If a page still fails after the
migration, read `docker compose logs --tail 40 solidtime`: step 3's `LOG_CHANNEL` puts the real
exception there rather than in the browser.
## 11. Out of scope
- Do not configure SMTP. The cost of `MAIL_MAILER="log"` is that resets and invitations are read
out of the container log, not an inbox, and a relay is its own deliverability problem.
- Do not add the Gotenberg container. It exists only so reports export as PDF, and CSV, XLSX and
ODS exports work without it.
- Do not set `AUTO_DB_MIGRATE`, for the reason step 9 gives.
- Do not turn `APP_ENABLE_REGISTRATION` back on. This host is public, and step 7's closure is
the only thing between the timesheet and whoever finds the hostname.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing solidtime 0.19.1 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, replace `<DOMAIN>` with the hostname whose A record already points at the
box, and replace `<ADMIN_EMAIL>` with the address you want on the first account.
Two things to decide before you start. `<DOMAIN>` becomes `APP_URL`, and solidtime rejects every
request whose Host header is neither that name nor a subdomain of it, so it has to be the name
you will actually use. `<ADMIN_EMAIL>` identifies the first account and goes into `SUPER_ADMINS`,
which is the list of people allowed into the server admin panel; this install configures no mail,
so it does not have to be a mailbox that works.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. The RAM floor is the one
to take seriously: this is three PHP containers on Laravel Octane plus a PostgreSQL, and on a
1 GB box the OOM killer arrives during the first migration rather than at a moment that explains
itself.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/solidtime /srv/solidtime/backups
sudo install -d -m 700 /srv/solidtime/postgres
sudo install -d -m 750 -o 1000 -g 1000 /srv/solidtime/storage
ls -la /srv/solidtime
```
You should see: `backups` owned by you, `postgres` at mode `drwx------` owned by root, and
`storage` owned by uid `1000`.
If you do not: leave `postgres` owned by root on purpose, because the PostgreSQL image chowns its
own data directory the first time it starts. The `1000` on `storage` is not arbitrary either: the
image copies the application in as that uid and runs as it, so a root-owned `storage` gives you
an application that cannot write an export. Finished exports, uploaded imports and profile
photos live in that directory; everything else is in the database.
## 3. Secrets
Four secrets end up on this box. Three are written here: `APP_KEY`, which encrypts session
cookies, the Passport private key, which signs API tokens, and the PostgreSQL password. The image
mints the first two itself with a command upstream documents for exactly this, which is why only
one of the three comes from `openssl`. Step 7 has the application generate the fourth, the
password on the first account.
The `>` on the first line is doing real work: without it the command prints an application key
and a private key onto your screen instead of into the file.
```bash
umask 077
docker run --rm solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd php artisan self-host:generate-keys > /srv/solidtime/.env
grep -c -E '^(APP_KEY|PASSPORT_PRIVATE_KEY|PASSPORT_PUBLIC_KEY)=' /srv/solidtime/.env
cat >> /srv/solidtime/.env <<EOF
APP_ENV="production"
APP_DEBUG="false"
APP_URL="https://<DOMAIN>"
APP_FORCE_HTTPS="true"
APP_ENABLE_REGISTRATION="false"
TRUSTED_PROXIES="172.16.0.0/12,192.168.0.0/16,10.0.0.0/8"
SUPER_ADMINS="<ADMIN_EMAIL>"
LOG_CHANNEL="stderr"
LOG_LEVEL="info"
DB_CONNECTION="pgsql"
DB_HOST="postgres"
DB_DATABASE="solidtime"
DB_USERNAME="solidtime"
DB_PASSWORD="$(openssl rand -hex 32)"
QUEUE_CONNECTION="database"
MAIL_MAILER="log"
SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE="false"
SCHEDULING_TASK_SELF_HOSTING_TELEMETRY="false"
EOF
chmod 600 /srv/solidtime/.env
umask 022
ls -l /srv/solidtime/.env
```
You should see: a `3` from the `grep -c`, then a listing whose mode is `-rw-------` with your own
username twice. Replace `<DOMAIN>` and `<ADMIN_EMAIL>` in the block with your real values before
you paste it.
If you do not: anything other than `3` means the first command wrote something unexpected into
the file before the configuration was appended, so `rm /srv/solidtime/.env` and run the block
again rather than editing around it. A mode of `-rw-r--r--` means `umask 077` did not take
effect, which happens when the lines are pasted separately into different shells; run
`chmod 600 /srv/solidtime/.env` and carry on. If the file already existed from an earlier
attempt, this block has now appended a second copy of everything, which PostgreSQL will not
forgive: delete it and start the block from the top.
Do not paste that file, any value from it, or any command output containing one into this chat
window. The agent path never shows those values to anybody; this path will hand them to a third
party unless you keep them out of the box you are typing in.
Three of those lines are decisions rather than defaults. `QUEUE_CONNECTION` matters because
Laravel's default is `sync` and the queue container exists to drain that table. `MAIL_MAILER`
is set to `log` because this install configures no SMTP, so an invitation or a password-reset
link is written into the container log instead of throwing an error. The last two ship switched
on upstream and are switched off here: both post this instance's URL to app.solidtime.io twice a
day, and the telemetry one adds counts of the users, organisations, projects, clients, tasks and
time entries in your database. Those two lines are where you turn either back on.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/solidtime/compose.yml <<'EOF'
# solidtime · the deterministic fallback. Authored by caniselfhostit from the
# upstream self-hosting documentation and the packaging at the pinned tag:
# docker guide ... https://docs.solidtime.io/self-hosting/guides/docker
# configuration .. https://docs.solidtime.io/self-hosting/configuration
# image build .... https://github.com/solidtime-io/solidtime/blob/v0.19.1/docker/prod/Dockerfile
#
# Four services, three of them the same image: CONTAINER_MODE picks HTTP,
# scheduler or queue worker, and there is no combined mode. Upstream's example
# ships a fifth, Gotenberg, only so reports export as PDF. Upstream pins
# postgres:15; their test matrix runs 15, 16 and 17, so this takes the newest.
# Digests read 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-solidtime: &solidtime
image: solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd
restart: unless-stopped
# The image copies the application in as uid 1000 and runs as it.
user: "1000:1000"
env_file: /srv/solidtime/.env
volumes:
# framework cache, shared; then the half worth keeping: exports/imports.
- solidtime-storage:/var/www/html/storage
- /srv/solidtime/storage:/var/www/html/storage/app
depends_on:
postgres:
condition: service_healthy
services:
postgres:
image: postgres:17.11-alpine@sha256:5d61573b31c206ae538c85893edeb6e320e1a9ffd838c0f9dca927fb6f765fa4
container_name: solidtime-db
restart: unless-stopped
environment:
POSTGRES_DB: solidtime
POSTGRES_USER: solidtime
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/solidtime/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U solidtime -d solidtime"]
interval: 10s
retries: 12
# No `ports:`: 5432 is reachable only from the other containers.
solidtime:
<<: *solidtime
container_name: solidtime
environment:
CONTAINER_MODE: http
ports:
# Loopback only: the host's Caddy is all that reaches 8184.
- "127.0.0.1:8184:8000"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/health-check/up"]
start_period: 60s
interval: 15s
retries: 10
scheduler:
<<: *solidtime
environment:
CONTAINER_MODE: scheduler
healthcheck:
# Ships with the image: asks supervisord if its process still runs.
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
queue:
<<: *solidtime
environment:
CONTAINER_MODE: worker
# Worker mode refuses to start without this.
WORKER_COMMAND: "php /var/www/html/artisan queue:work"
healthcheck:
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
volumes:
solidtime-storage:
EOF
cd /srv/solidtime && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal, so run `rm /srv/solidtime/compose.yml` and paste again in one go. A warning that
`DB_PASSWORD` is not set means step 3 wrote its file somewhere other than /srv/solidtime, or you
are not in /srv/solidtime: compose reads `.env` from the directory you run it in. Note what is
here. `x-solidtime` is a compose extension field, and the three `<<: *solidtime` lines merge it
into the three application services, so the pinned image and the shared mounts are written once
instead of three times. Do not delete the `queue` service to save memory: every time entry you
save queues a job that recalculates the spent time on its project and its task, so an install
with no worker looks perfectly healthy and reports totals that never move.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-solidtime
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# solidtime · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.solidtime.io/self-hosting/configuration and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# APP_URL in .env, and the app rejects any Host that is neither it nor a
# subdomain of it.
<DOMAIN> {
# APP_ENABLE_REGISTRATION is already false in the app; this also takes the
# signup form off the hostname.
respond /register 404
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy: written untested it blanks a single-page app.
# No `encode`: the image runs Octane on FrankenPHP, whose own Caddy already
# compresses. 8184 is the loopback port compose publishes here, not a
# container port, and not open in the firewall.
reverse_proxy 127.0.0.1:8184
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-solidtime /etc/caddy/Caddyfile`, reload,
and paste again. The `respond /register 404` line is not decoration. solidtime already refuses to
create accounts through the sign-up form because `APP_ENABLE_REGISTRATION` is false, and this
takes the form off your hostname entirely, so there are two independent answers to anybody who
finds the URL. Step 7 checks both.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8184` or `5432`.
If you do not: delete anything for those two with `sudo ufw delete allow 8184`. 8184 is bound to
127.0.0.1 by the compose file and 5432 is never published at all, so the database has no host
port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and answer the ACME
challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has switched it off since, and `sudo ufw enable` puts it back before you go any further.
## 7. Start and verify
PostgreSQL initialises first, then the three application containers come up. Read the next
paragraph before you worry about anything you see here: the health endpoint answers before the
database schema exists, because upstream deliberately built it to touch neither the database nor
the cache, and sessions live in a database table, so every page is a 500 until the migration has
run.
```bash
cd /srv/solidtime
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health-check/up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health-check/up; echo
docker compose exec -T solidtime php artisan migrate --force
curl -sS https://<DOMAIN>/login | grep -o '<title inertia>[^<]*</title>'
docker compose ps
```
You should see, in order: the loop reaching `200`, then `{"success":true}`, then a list of
migrations each ending in `DONE`, then `<title inertia>solidtime</title>`, then four services
with `solidtime-db` and `solidtime` reported healthy.
If you do not: a `502` from the loop means Caddy is reaching nothing on 8184, so check
`docker compose ps` and then `docker compose logs --tail 40 solidtime`. An `SQLSTATE[08006]` in
the migration means the application container never received the password from step 3, which is
step 3 written to the wrong directory. A `403` on every request means `<DOMAIN>` and the
`APP_URL` line in `.env` are different strings, and solidtime rejects the Host header it does not
recognise. A running container is not success; all of these have to pass.
In a browser, https://<DOMAIN> redirects to https://<DOMAIN>/login, which shows an `Email` box,
a `Password` box and a `Log in` button.
Now make the only account this instance starts with. There is no signup form to race, because
registration is off, so the account is created on the command line and the command prints a
generated password. That is why the output goes into a file rather than onto your screen. If you
want a display name other than your address, put it in place of the first quoted value.
```bash
cd /srv/solidtime
umask 077
docker compose exec -T solidtime php artisan admin:user:create "<ADMIN_EMAIL>" "<ADMIN_EMAIL>" --verify-email > /srv/solidtime/first-account.txt
chmod 600 /srv/solidtime/first-account.txt
umask 022
grep -c '^Password: ' /srv/solidtime/first-account.txt
```
You should see: `1`.
If you do not: `0` means the command failed and wrote its error into the file instead, so read it
with `sudo cat /srv/solidtime/first-account.txt`. `User with email ... already exists` means you
have run this twice; the first account is fine, and you can skip to reading the password below.
Read your password once and sign in before you go on, because the next block destroys the
server's copy of it:
```bash
sudo grep '^Password: ' /srv/solidtime/first-account.txt
```
You should see: one line beginning `Password: `. Put that value in your password manager now,
then sign in at https://<DOMAIN> with `<ADMIN_EMAIL>` and confirm the dashboard shows a
`This Week` card.
If you do not: an empty result means the block above did not run in this shell. Go back rather
than inventing a password here. Do not paste the value into this chat window.
Now close the front door and prove it is closed:
```bash
cd /srv/solidtime
shred -u /srv/solidtime/first-account.txt
docker compose exec -T solidtime printenv APP_ENABLE_REGISTRATION
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/users/me
```
You should see: no output from `shred`, then `false`, then `404`, then `401`.
If you do not: `true` from `printenv` means the containers are still running on an older copy of
`.env`, so run `docker compose up -d --force-recreate` and check again. A `200` on `/register`
means the Caddy block from step 5 did not land, so re-read `/etc/caddy/Caddyfile` and reload.
Anything other than `401` on the API call is worth stopping for: that call carries no token, and
`401` is the proof that nobody reads your time entries without one. The `false` and the `404`
together are the two independent answers to a stranger who finds your hostname, and the `shred`
is your generated password leaving the box.
## 8. First backup and restore
Two artifacts. The database holds every organisation, project, client, rate and time entry. The
file archive holds compose.yml, .env, the live Caddy site block and `storage`.
```bash
cd /srv/solidtime
docker compose exec -T postgres pg_dump -U solidtime -d solidtime | gzip > /srv/solidtime/backups/solidtime-db-$(date +%F).sql.gz
sudo tar -czf /srv/solidtime/backups/solidtime-files-$(date +%F).tar.gz -C /srv/solidtime compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/solidtime/backups/
```
You should see: two files, the dump a few tens of kilobytes on a fresh install and the archive
larger. Nothing goes offline: `pg_dump` snapshots a running database consistently.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error. A `tar`
that complains about `Caddyfile` means the site block from step 5 was never appended.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/solidtime
scp vps:/srv/solidtime/backups/* ~/backups/solidtime/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/solidtime/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty timesheet:
```bash
cd /srv/solidtime
sudo tar -xzf /srv/solidtime/backups/solidtime-files-$(date +%F).tar.gz -C /srv/solidtime compose.yml .env
docker compose down
sudo rm -rf /srv/solidtime/postgres
sudo install -d -m 700 /srv/solidtime/postgres
docker compose up -d postgres
sleep 45
gunzip -c /srv/solidtime/backups/solidtime-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U solidtime -d solidtime
docker compose up -d
sleep 60
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/login
```
You should see: a stream of `CREATE TABLE` and `COPY` lines from the restore, then `200` from the
last command, then your own account still working when you sign in.
If you do not: `password authentication failed for user "solidtime"` means `.env` was not back
before the database container initialised its empty directory, which is why the untar is the
first line rather than the last. `could not connect to server` means PostgreSQL had not finished
starting, so wait longer and run the `gunzip` line again. Understand what is at stake: an hour
you tracked and cannot produce is an hour you cannot invoice, and `APP_KEY` lives in that same
`.env`, so a database restored without it signs everyone out permanently.
## 9. Updating later
New versions are listed at https://github.com/solidtime-io/solidtime/releases. The cadence is the
load-bearing fact about this project, so read it before you decide how often to look: the first
tag was June 2024, this is still a 0.x line, and it has shipped roughly two releases a month
through 2026, with 0.19.1 landing on 7 August 2026. That is a young project moving quickly, so
read the release notes rather than skimming the version number. Take both backup artifacts first,
then edit the single `image:` line in the `x-solidtime` block of /srv/solidtime/compose.yml to
the new tag and its digest.
```bash
cd /srv/solidtime
docker compose pull
docker compose up -d
docker compose exec -T solidtime php artisan migrate --force
docker compose logs --tail 30 solidtime
```
You should see: the pull finishing, four containers recreated, migration output, and no repeating
restart in the log.
If you do not: put the old tag and digest back and run the same commands. The migration is a
separate command on purpose. Upstream offers an `AUTO_DB_MIGRATE` variable that runs it at
container start; this install leaves it unset, so an image pull can never rewrite your schema
before you have a dump of the old one. Re-run step 7's first two checks before you call the
update done.
## 10. What will probably go wrong
The first thing I did after `docker compose up -d` was load the site, get a 500, and start
pulling the compose file apart. Nothing was broken. The health endpoint answers `200` from the
moment Octane is listening, because upstream built it to touch neither the database nor the
cache, and sessions live in a table that does not exist until `php artisan migrate` has run, so
for a minute or two the box looks healthy and every page fails. Run the migration before you
believe anything. If a page still fails after it, read `docker compose logs --tail 40 solidtime`
rather than the browser: the `LOG_CHANNEL="stderr"` line from step 3 is what puts the real
exception there instead of in a file inside the container.
## 11. Out of scope
- Do not configure SMTP. The cost of `MAIL_MAILER="log"` is that resets and invitations are read
out of the container log instead of an inbox, and a relay is its own deliverability problem.
- Do not add the Gotenberg container. It exists only so reports export as PDF, and CSV, XLSX and
ODS exports work without it.
- Do not set `AUTO_DB_MIGRATE`, for the reason step 9 gives.
- Do not turn `APP_ENABLE_REGISTRATION` back on. This host is public, and step 7's closure is the
only thing between the timesheet and whoever finds the hostname.375 lines · 16,488 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install solidtime 0.19.1 under ~/selfhost/solidtime, answering at http://localhost:8184.
## 1. Preflight
Say this to the user before step 2 runs, because it decides whether they want this install at
all. solidtime is built around organisations, members and billable rates, and this copy answers
at http://localhost:8184 and nowhere else: no colleague can be invited in, no phone can reach
it, and it is gone the moment the lid closes. What is left is a real tracker for their own
hours.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. solidtime needs 2048 MB of RAM available
and 10 GB free on the home disk: three PHP containers plus a PostgreSQL, both images amd64 and
arm64. If either is under its floor, print both and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/solidtime/storage ~/selfhost/solidtime/backups
if [ "$(uname -s)" = "Linux" ]; then sudo chown -R 1000:1000 ~/selfhost/solidtime/storage; fi
ls -la ~/selfhost/solidtime
```
Assert: `ls -la` shows `storage` and `backups`. The image runs as uid 1000, so on Linux that
directory has to belong to 1000 or no export can be written; on macOS and Windows the chown is
skipped because Docker Desktop maps ownership.
## 4. Secrets
Four secrets end up here. `APP_KEY` for session cookies, the Passport private key that signs API
tokens and the PostgreSQL password are written now; step 7 has the application generate the
account password. The image mints the first two, so the third uses `openssl`. Print none of
them: the redirect below keeps the minted keys off the terminal.
```bash
umask 077
docker run --rm solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd php artisan self-host:generate-keys > ~/selfhost/solidtime/.env
grep -c -E '^(APP_KEY|PASSPORT_PRIVATE_KEY|PASSPORT_PUBLIC_KEY)=' ~/selfhost/solidtime/.env
cat >> ~/selfhost/solidtime/.env <<EOF
APP_ENV="production"
APP_DEBUG="false"
APP_URL="http://localhost:8184"
APP_FORCE_HTTPS="false"
APP_ENABLE_REGISTRATION="false"
LOG_CHANNEL="stderr"
LOG_LEVEL="info"
DB_CONNECTION="pgsql"
DB_HOST="postgres"
DB_DATABASE="solidtime"
DB_USERNAME="solidtime"
DB_PASSWORD="$(openssl rand -hex 32)"
QUEUE_CONNECTION="database"
MAIL_MAILER="log"
SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE="false"
SCHEDULING_TASK_SELF_HOSTING_TELEMETRY="false"
EOF
chmod 600 ~/selfhost/solidtime/.env
umask 022
ls -l ~/selfhost/solidtime/.env
```
Assert both: `grep -c` prints `3` and the file is mode `-rw-------`. Anything but `3` means the
image wrote something unexpected into it, so delete the file and run the block again. On Windows
those mode bits are advisory; the real boundary is the user's own account.
`QUEUE_CONNECTION` is required because Laravel defaults to `sync` and the queue container drains
that table. `MAIL_MAILER="log"` puts reset links in the log instead of throwing. The last two
are upstream defaults, off here because both post this instance's URL to app.solidtime.io twice
a day and telemetry adds object counts.
## 5. compose.yml
```bash
cat > ~/selfhost/solidtime/compose.yml <<'EOF'
# solidtime · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream self-hosting documentation and the packaging
# at the pinned tag:
# docker guide ... https://docs.solidtime.io/self-hosting/guides/docker
# configuration .. https://docs.solidtime.io/self-hosting/configuration
# image build .... https://github.com/solidtime-io/solidtime/blob/v0.19.1/docker/prod/Dockerfile
#
# Four services from ~/selfhost/solidtime/, three of them the same image:
# CONTAINER_MODE picks HTTP, scheduler or queue worker. Gotenberg, upstream's
# PDF renderer, is left out. The database is a named volume because PostgreSQL
# chowns its data directory to a uid Docker Desktop cannot grant on a Windows
# home bind mount; ./storage stays a bind mount. Digests read 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-solidtime: &solidtime
image: solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd
restart: unless-stopped
# The image copies the application in as uid 1000 and runs as it.
user: "1000:1000"
env_file: .env
volumes:
# framework cache, shared; then the half worth keeping: exports/imports.
- solidtime-storage:/var/www/html/storage
- ./storage:/var/www/html/storage/app
depends_on:
postgres:
condition: service_healthy
services:
postgres:
image: postgres:17.11-alpine@sha256:5d61573b31c206ae538c85893edeb6e320e1a9ffd838c0f9dca927fb6f765fa4
container_name: solidtime-db
restart: unless-stopped
environment:
POSTGRES_DB: solidtime
POSTGRES_USER: solidtime
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- solidtime-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U solidtime -d solidtime"]
interval: 10s
retries: 12
# No `ports:`: 5432 is reachable only from the other containers.
solidtime:
<<: *solidtime
container_name: solidtime
environment:
CONTAINER_MODE: http
ports:
# Loopback only: no other device on the wifi can reach 8184.
- "127.0.0.1:8184:8000"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/health-check/up"]
start_period: 60s
interval: 15s
retries: 10
scheduler:
<<: *solidtime
environment:
CONTAINER_MODE: scheduler
healthcheck:
# Ships with the image: asks supervisord if its process runs.
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
queue:
<<: *solidtime
environment:
CONTAINER_MODE: worker
# Worker mode refuses to start without this.
WORKER_COMMAND: "php /var/www/html/artisan queue:work"
healthcheck:
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
volumes:
solidtime-postgres:
solidtime-storage:
EOF
cd ~/selfhost/solidtime && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Do not delete `queue`: every saved time entry queues a job
recalculating spent time on its project and task, so without a worker the totals never move.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. A certificate
attests a public name and this has none; browsers treat http://localhost as a secure context
anyway, so the session cookies behave, and nothing is published beyond loopback.
8184 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not anyone on the
internet. For a personal timesheet that is a fair trade. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/solidtime/compose.yml
```
Assert: that prints `1`, the single published port. `0` means step 5 did not land; more than `1`
means a second published port appeared, and the install stops.
## 7. Start and verify
The health endpoint answers before the schema exists, because upstream wrote it to touch neither
the database nor the cache, and sessions live in a table, so the login page is a 500 until
migrate has run.
```bash
cd ~/selfhost/solidtime
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8184/health-check/up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8184/health-check/up; echo
docker compose exec -T solidtime php artisan migrate --force
curl -sS http://localhost:8184/login | grep -o '<title inertia>[^<]*</title>'
```
Assert all four and print each: the loop ends on `200`, the health body is `{"success":true}`,
the migration prints migrations each ending `DONE` with no exception, and the title prints
`<title inertia>solidtime</title>`. If any misses, stop and run
`docker compose logs --tail 40 solidtime`. `port is already allocated` means something else holds
8184: find it with `lsof -nP -iTCP:8184 -sTCP:LISTEN` and stop until the user frees it. A running
container is not success.
Registration is off, so the only account is made on the command line. It prints a generated
password, so the output goes to a file. Put the user's display name and email address in place
of the two quoted values; that address identifies the account, nothing is sent to it.
```bash
cd ~/selfhost/solidtime
umask 077
docker compose exec -T solidtime php artisan admin:user:create "Your Name" "you@example.com" --verify-email > ~/selfhost/solidtime/first-account.txt
chmod 600 ~/selfhost/solidtime/first-account.txt
umask 022
grep -c '^Password: ' ~/selfhost/solidtime/first-account.txt
```
Assert: the count prints `1`. `--verify-email` marks the address verified without sending mail,
which is what the account needs here.
STOP: tell the user to read their password with
`grep '^Password: ' ~/selfhost/solidtime/first-account.txt`, put it in their password manager,
sign in at http://localhost:8184, and confirm the dashboard shows a `This Week` card.
Do not continue until they confirm. The next block destroys that file.
Once they confirm, close the account door and prove it is closed:
```bash
cd ~/selfhost/solidtime
rm -f ~/selfhost/solidtime/first-account.txt
docker compose exec -T solidtime printenv APP_ENABLE_REGISTRATION
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8184/api/v1/users/me
```
Assert both and print each. `printenv` prints `false`, the application's own answer: its sign-up
action refuses to create a user while that is off, so the register route cannot mint an account.
The API call prints `401`: nothing reads time entries without a token.
## 8. First backup and restore
Two artifacts: a dump of the database holding every project, client, rate and time entry, and an
archive of compose.yml, .env and `storage`.
```bash
cd ~/selfhost/solidtime
docker compose exec -T postgres pg_dump -U solidtime -d solidtime | gzip > ~/selfhost/solidtime/backups/solidtime-db-$(date +%F).sql.gz
tar -C ~/selfhost/solidtime -czf ~/selfhost/solidtime/backups/solidtime-files-$(date +%F).tar.gz compose.yml .env storage
ls -lh ~/selfhost/solidtime/backups/
```
Assert: both exist and both are non-empty, and print both sizes. Nothing goes offline: `pg_dump`
snapshots a running database consistently.
Those files are on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a synced folder or a USB
stick, and copy both there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms both names are listed there.
To restore, in this order. Untar the file archive into ~/selfhost/solidtime first, so `.env` is
back before any container starts: PostgreSQL takes its password from it the moment it
initialises an empty data directory. Then `docker compose down -v`, then
`docker compose up -d postgres`, wait for healthy, then
`gunzip -c ~/selfhost/solidtime/backups/solidtime-db-<date>.sql.gz | docker compose exec -T postgres psql -U solidtime -d solidtime`,
then `docker compose up -d` and re-run step 7's checks. `APP_KEY` is in that `.env`, so a
database restored without it signs everyone out for good.
## 9. Updating later
New versions are at https://github.com/solidtime-io/solidtime/releases. Say the cadence, it is
the load-bearing fact here: the first tag was June 2024, this is still a 0.x line, and it has
shipped roughly two releases a month through 2026, with 0.19.1 landing on 7 August 2026. Read
the notes, back up, then edit the `image:` line:
```bash
cd ~/selfhost/solidtime
docker compose pull
docker compose up -d
docker compose exec -T solidtime php artisan migrate --force
docker compose logs --tail 30 solidtime
```
The migration is separate on purpose: upstream's `AUTO_DB_MIGRATE` runs it at container start,
and leaving it unset means an image pull can never rewrite the schema before there is a dump of
the old one. Re-run step 7's checks first.
## 10. What will probably go wrong
I rebooted, opened http://localhost:8184 out of habit, and got nothing at all. Docker Desktop
had not started, so none of the four containers were running, and a tracker that is not running
is a day of hours reconstructed from memory on Friday. Turn on Docker Desktop's start-at-login
setting, and after any reboot run `cd ~/selfhost/solidtime && docker compose up -d` before you
trust a timer.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not configure SMTP. With `MAIL_MAILER="log"` a reset link is read out of
`docker compose logs solidtime`, which is enough for one person on one machine.
- Do not add the Gotenberg container. It only makes reports export as PDF; CSV, XLSX and ODS
work without it.
- Do not turn `APP_ENABLE_REGISTRATION` back on.compose.local.ymlthe services, pinned · local layout86 lines
# solidtime · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream self-hosting documentation and the packaging
# at the pinned tag:
# docker guide ... https://docs.solidtime.io/self-hosting/guides/docker
# configuration .. https://docs.solidtime.io/self-hosting/configuration
# image build .... https://github.com/solidtime-io/solidtime/blob/v0.19.1/docker/prod/Dockerfile
#
# Four services from ~/selfhost/solidtime/, three of them the same image:
# CONTAINER_MODE picks HTTP, scheduler or queue worker. Gotenberg, upstream's
# PDF renderer, is left out. The database is a named volume because PostgreSQL
# chowns its data directory to a uid Docker Desktop cannot grant on a Windows
# home bind mount; ./storage stays a bind mount. Digests read 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-solidtime: &solidtime
image: solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd
restart: unless-stopped
# The image copies the application in as uid 1000 and runs as it.
user: "1000:1000"
env_file: .env
volumes:
# framework cache, shared; then the half worth keeping: exports/imports.
- solidtime-storage:/var/www/html/storage
- ./storage:/var/www/html/storage/app
depends_on:
postgres:
condition: service_healthy
services:
postgres:
image: postgres:17.11-alpine@sha256:5d61573b31c206ae538c85893edeb6e320e1a9ffd838c0f9dca927fb6f765fa4
container_name: solidtime-db
restart: unless-stopped
environment:
POSTGRES_DB: solidtime
POSTGRES_USER: solidtime
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- solidtime-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U solidtime -d solidtime"]
interval: 10s
retries: 12
# No `ports:`: 5432 is reachable only from the other containers.
solidtime:
<<: *solidtime
container_name: solidtime
environment:
CONTAINER_MODE: http
ports:
# Loopback only: no other device on the wifi can reach 8184.
- "127.0.0.1:8184:8000"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/health-check/up"]
start_period: 60s
interval: 15s
retries: 10
scheduler:
<<: *solidtime
environment:
CONTAINER_MODE: scheduler
healthcheck:
# Ships with the image: asks supervisord if its process runs.
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
queue:
<<: *solidtime
environment:
CONTAINER_MODE: worker
# Worker mode refuses to start without this.
WORKER_COMMAND: "php /var/www/html/artisan queue:work"
healthcheck:
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
volumes:
solidtime-postgres:
solidtime-storage:agent-readable mirror: /self-host/clockify.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned84 lines
# solidtime · the deterministic fallback. Authored by caniselfhostit from the
# upstream self-hosting documentation and the packaging at the pinned tag:
# docker guide ... https://docs.solidtime.io/self-hosting/guides/docker
# configuration .. https://docs.solidtime.io/self-hosting/configuration
# image build .... https://github.com/solidtime-io/solidtime/blob/v0.19.1/docker/prod/Dockerfile
#
# Four services, three of them the same image: CONTAINER_MODE picks HTTP,
# scheduler or queue worker, and there is no combined mode. Upstream's example
# ships a fifth, Gotenberg, only so reports export as PDF. Upstream pins
# postgres:15; their test matrix runs 15, 16 and 17, so this takes the newest.
# Digests read 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-solidtime: &solidtime
image: solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd
restart: unless-stopped
# The image copies the application in as uid 1000 and runs as it.
user: "1000:1000"
env_file: /srv/solidtime/.env
volumes:
# framework cache, shared; then the half worth keeping: exports/imports.
- solidtime-storage:/var/www/html/storage
- /srv/solidtime/storage:/var/www/html/storage/app
depends_on:
postgres:
condition: service_healthy
services:
postgres:
image: postgres:17.11-alpine@sha256:5d61573b31c206ae538c85893edeb6e320e1a9ffd838c0f9dca927fb6f765fa4
container_name: solidtime-db
restart: unless-stopped
environment:
POSTGRES_DB: solidtime
POSTGRES_USER: solidtime
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/solidtime/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U solidtime -d solidtime"]
interval: 10s
retries: 12
# No `ports:`: 5432 is reachable only from the other containers.
solidtime:
<<: *solidtime
container_name: solidtime
environment:
CONTAINER_MODE: http
ports:
# Loopback only: the host's Caddy is all that reaches 8184.
- "127.0.0.1:8184:8000"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/health-check/up"]
start_period: 60s
interval: 15s
retries: 10
scheduler:
<<: *solidtime
environment:
CONTAINER_MODE: scheduler
healthcheck:
# Ships with the image: asks supervisord if its process still runs.
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
queue:
<<: *solidtime
environment:
CONTAINER_MODE: worker
# Worker mode refuses to start without this.
WORKER_COMMAND: "php /var/www/html/artisan queue:work"
healthcheck:
test: ["CMD", "healthcheck"]
start_period: 60s
interval: 30s
retries: 5
volumes:
solidtime-storage:Caddyfilethe hostname and TLS29 lines
# solidtime · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.solidtime.io/self-hosting/configuration and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# APP_URL in .env, and the app rejects any Host that is neither it nor a
# subdomain of it.
<DOMAIN> {
# APP_ENABLE_REGISTRATION is already false in the app; this also takes the
# signup form off the hostname.
respond /register 404
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy: written untested it blanks a single-page app.
# No `encode`: the image runs Octane on FrankenPHP, whose own Caddy already
# compresses. 8184 is the loopback port compose publishes here, not a
# container port, and not open in the firewall.
reverse_proxy 127.0.0.1:8184
}install.shthe same install, no agent211 lines
#!/usr/bin/env bash
# solidtime · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=time.example.com ADMIN_EMAIL=you@example.com \
# ADMIN_NAME="Your Name" ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://docs.solidtime.io/self-hosting/guides/docker
# https://docs.solidtime.io/self-hosting/configuration
# https://docs.solidtime.io/self-hosting/container-mode
# https://docs.solidtime.io/self-hosting/cli-commands
#
# Four secrets end up on this machine. Three go into /srv/solidtime/.env at mode
# 600: APP_KEY, the Passport private key and the PostgreSQL password. The image
# mints the first two with its own `self-host:generate-keys` command, which is
# why only one of the three comes from `openssl rand` here. The fourth is the
# password on the first account, which the application generates and prints; the
# script sends that output straight into a mode-600 file instead of the
# terminal, and the summary tells you where to read it.
#
# DOMAIN_HOST is also APP_URL. The application rejects any request whose Host
# header is neither that name nor a subdomain of it, so it has to be the name
# you will actually use.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/solidtime}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
ADMIN_NAME="${ADMIN_NAME:-}"
IMAGE="solidtime/solidtime:0.19.1@sha256:419ae59a806bcd6b15e9b637b5cee4800f7eb8f4941e20f4c5416d71acd5f1dd"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. time.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address for the first solidtime account"
[ -n "$ADMIN_NAME" ] || die "set ADMIN_NAME to the display name for the first account, e.g. 'Your Name'"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; three PHP containers plus PostgreSQL want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# postgres stays root-owned: the PostgreSQL image chowns its own data directory
# on first start. storage is chowned to 1000:1000, the uid the application
# containers run as, because that bind mount is the one they write to.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/storage"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the secrets, on the server ----------------------------------
#
# The image's own command mints APP_KEY and the Passport keypair and prints them
# in env-file form, so the redirect below is what keeps them off the terminal.
# Read them later with:
# sudo grep -E 'APP_KEY|DB_PASSWORD' /srv/solidtime/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
docker run --rm "$IMAGE" php artisan self-host:generate-keys > "$APP_DIR/.env"
keys="$(grep -c -E '^(APP_KEY|PASSPORT_PRIVATE_KEY|PASSPORT_PUBLIC_KEY)=' "$APP_DIR/.env")"
[ "$keys" = "3" ] || die "generate-keys wrote ${keys} key lines instead of 3. Inspect $APP_DIR/.env and delete it before retrying."
cat >> "$APP_DIR/.env" <<-ENVFILE
APP_ENV="production"
APP_DEBUG="false"
APP_URL="https://${DOMAIN_HOST}"
APP_FORCE_HTTPS="true"
APP_ENABLE_REGISTRATION="false"
TRUSTED_PROXIES="172.16.0.0/12,192.168.0.0/16,10.0.0.0/8"
SUPER_ADMINS="${ADMIN_EMAIL}"
LOG_CHANNEL="stderr"
LOG_LEVEL="info"
DB_CONNECTION="pgsql"
DB_HOST="postgres"
DB_DATABASE="solidtime"
DB_USERNAME="solidtime"
DB_PASSWORD="$(openssl rand -hex 32)"
QUEUE_CONNECTION="database"
MAIL_MAILER="log"
SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE="false"
SCHEDULING_TASK_SELF_HOSTING_TELEMETRY="false"
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-solidtime"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8184 nor 5432 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8184 and 5432 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it, then build the schema --------------------------------------
#
# The health endpoint answers before the schema exists, because it deliberately
# touches neither the database nor the cache. The login page needs the sessions
# table, so the migration has to run before anything else is believed.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/health-check/up"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health-check/up" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/health-check/up answered ${code:-nothing}. Check: docker compose logs --tail 40 solidtime"
docker compose exec -T solidtime php artisan migrate --force
curl -sS "https://${DOMAIN_HOST}/login" | grep -q '<title inertia>solidtime</title>' \
|| die "the login page did not carry the solidtime title. Check: docker compose logs --tail 40 solidtime"
# --- 7. The first account, and the closed front door -------------------------
#
# admin:user:create prints a generated password on stdout. That redirect is the
# whole point: the value lands in a file only you can read, never in this log.
if [ ! -f "$APP_DIR/first-account.txt" ]; then
umask 077
docker compose exec -T solidtime php artisan admin:user:create "$ADMIN_NAME" "$ADMIN_EMAIL" --verify-email \
> "$APP_DIR/first-account.txt"
chmod 600 "$APP_DIR/first-account.txt"
umask 022
fi
grep -q '^Password: ' "$APP_DIR/first-account.txt" \
|| die "no password line in $APP_DIR/first-account.txt. Read the file, then delete it and run this again."
reg_env="$(docker compose exec -T solidtime printenv APP_ENABLE_REGISTRATION || echo UNSET)"
echo "==> APP_ENABLE_REGISTRATION inside the container: ${reg_env}"
[ "$reg_env" = "false" ] || die "registration is ${reg_env} inside the container, not false. Fix $APP_DIR/.env and recreate."
reg_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/register" || true)"
echo "==> https://${DOMAIN_HOST}/register answered ${reg_code}"
[ "$reg_code" = "404" ] || die "the signup path answered ${reg_code} instead of 404. Check the Caddy site block."
api_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/users/me" || true)"
echo "==> unauthenticated API call answered ${api_code}"
[ "$api_code" = "401" ] || die "the API answered ${api_code} instead of 401 without a token. Stop and investigate."
# --- 8. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U solidtime -d solidtime \
| gzip > "$APP_DIR/backups/solidtime-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/solidtime-files-${STAMP}.tar.gz" \
-C "$APP_DIR" compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/solidtime-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/solidtime-files-${STAMP}.tar.gz" ] || die "the file archive is empty"
cat <<-DONE
solidtime is answering at https://${DOMAIN_HOST}/login
1. Read the password for the first account now:
sudo grep '^Password: ' $APP_DIR/first-account.txt
It was not printed here. Put it in your password manager, then sign in
at https://${DOMAIN_HOST} with ${ADMIN_EMAIL} and confirm the dashboard
shows a "This Week" card.
2. Once you are signed in, delete that file. It is the only copy on the
box and it does not need to stay:
shred -u $APP_DIR/first-account.txt
If you lose the password afterwards, mail is not configured, so the
reset link goes to the container log instead of an inbox:
docker compose logs --tail 200 solidtime
3. ${ADMIN_EMAIL} is in SUPER_ADMINS, so the same sign-in also opens the
server admin panel at https://${DOMAIN_HOST}/admin. That panel can
break the instance; it is not where you track time.
4. First backup written to $APP_DIR/backups: a database dump and a file
archive holding compose.yml, .env, storage and the live Caddy site
block. They are on the same disk as the data, which is not a backup.
Copy them off the box tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Clockify.
- This is a young project on a 0.x line, and that is the first thing to weigh. The first tag is dated June 2024, the version pinned here is 0.19.1 from 7 August 2026, and upstream has been shipping roughly two releases a month through 2026. That cadence is a good sign about attention and a bad sign about stability: read the release notes rather than skimming the number, and expect the schema to move under you more often than a decade-old timesheet would move.
- Four containers, not one. The published image runs one role at a time, chosen by CONTAINER_MODE, so the web server, the scheduler and the queue worker are three containers of the same image, plus a PostgreSQL. The worker is not optional decoration: every time entry you save queues a job that recalculates the spent time on its project and its task, so an install without it looks healthy and reports totals that never move.
- No mail unless you set it up. This install runs with the log mailer, so a password-reset link or an organisation invitation is written into the container log instead of an inbox. That is workable for one person and a real obstacle to the team half of the product: inviting a colleague means fishing a signed URL out of `docker compose logs`.
- Two things phone home by default, and this install turns both off. An update check and a telemetry ping post your instance URL to app.solidtime.io twice a day, and the telemetry one adds counts of your users, organisations, projects, clients, tasks and time entries. The compose file names both switches so you can decide for yourself rather than discover it later.
- No paywall in the self-hosted build, and no PDF either. The open-source billing contract answers yes to every subscription check, so rounding, exports and more than one member are all on. The one thing missing is PDF report export, which needs a fifth container running Gotenberg; CSV, XLSX and ODS work without it.
Where this came from
“Depending on your configuration of the APP_ENABLE_REGISTRATION environment variable, you can either create a user via the registration form or you need to create a user via the CLI. Per default the registration is disabled.”
- Registration is off unless you switch it on: APP_ENABLE_REGISTRATION defaults to false at the pinned tag, and the sign-up action throws a validation error rather than creating a user while it is false. source
- One image serves three roles. CONTAINER_MODE selects http, scheduler or worker and there is no combined mode, so a queue worker and a scheduler are separate containers of the same image. source
- Accounts are created with artisan admin:user:create, which generates and prints a random password unless --ask-for-password is passed, and --verify-email marks the address verified without sending anything. source
- Two scheduled tasks are on by default and post to app.solidtime.io twice a day: an update check sending version, build and your APP_URL, and a telemetry ping adding counts of users, organisations, audits, projects, project members, clients, tasks and time entries. source
- The open-source BillingContract returns true from hasSubscription and false from isBlocked, so a self-hosted organisation is treated as a paid one; the metered implementation lives in a cloud-only extension. source
- Upstream's own self-hosting example runs the application, a scheduler, a queue worker, PostgreSQL and a Gotenberg PDF renderer, publishing only the application port. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Clockify?
Not Clockify itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is solidtime. A modern time tracker with organisations, members, clients and billable rates, on a box that never counts seats. The install is one weekend: 4 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 240 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Clockify?
solidtime. A modern time tracker with organisations, members, clients and billable rates, on a box that never counts seats. The closest thing to Clockify's shape that you can run yourself: a modern tracker interface, organisations with members and roles, clients, projects and tasks, and billable rates set per organisation, per project or per member. Nothing in the self-hosted build is metered, because the open-source billing contract answers yes to every subscription check, so rounding, exports and more than one member are all available. Be clear-eyed about the age: the project's first tag is June 2024, this is a 0.x line shipping roughly two releases a month, and it is four containers rather than one. Reports export as CSV, XLSX and ODS; PDF needs a fifth container this install does not run. Also evaluated and ranked below it: Kimai. The prompt on this page installs solidtime only. solidtime is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Clockify?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. solidtime itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Clockify Basic, $14.97/mo — $179.64 a year, 3 seats assumed.
How hard is it really?
ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the solidtime install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run solidtime on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same solidtime on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer solidtime answers at http://localhost:8184 and nowhere else, so the organisation, the members and the invitations are features you can see and cannot use, and a timer nobody but you can reach is a timer that stops when the lid closes. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.