# Can I self-host Clockify?

**YES, BUT** — it's called solidtime. ONE WEEKEND setup · ~4 hours to running · 2 GB RAM minimum · $14.97/mo you stop paying ($179.64/yr on the Basic plan, 3 seats assumed).

solidtime authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/clockify/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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:
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

## Also evaluated

Ranked below solidtime for this swap. The prompts above install solidtime only.

- **Kimai** — 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 page this mirrors: https://caniselfhostit.com/self-host/clockify/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
