DocsSelf-hosting
Running Spindle on your own server
Run Spindle from one Docker image and a Postgres database, with every feature for tracking and dividing time. Self-hosting is free.
On this page
Spindle runs on your own server from one Docker image, ghcr.io/threennovate/spindle, next to a PostgreSQL database. The image serves the web app and the API that the desktop and phone apps talk to. It is the same app as Spindle Cloud, and running it is free.
What you need
- A server with Docker and Docker Compose. A small one is enough to start: in our own deployment, Spindle uses about 90 MB of memory at rest and its database about 45 MB. Watch it under your own use before you settle on a size.
- A domain name pointing at the server, such as
time.example.com. - An SMTP server to send email from. Spindle sends invitations and sign-in emails, and it doesn’t start without one.
Start it
Put these two files in an empty folder on the server. The compose file runs Spindle, PostgreSQL 18, and Caddy in front of them for HTTPS.
compose.yaml
services:
spindle:
image: ghcr.io/threennovate/spindle:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
ConnectionStrings__Default: Host=db;Port=5432;Database=spindle;Username=spindle;Password=${POSTGRES_PASSWORD}
Auth__SigningKey: ${SIGNING_KEY}
App__BaseUrl: https://${DOMAIN}
AllowedHosts: ${DOMAIN};localhost;127.0.0.1
ReverseProxy__KnownNetworks__0: 172.28.0.0/24
Smtp__Host: ${SMTP_HOST}
Smtp__Port: ${SMTP_PORT}
Smtp__UserName: ${SMTP_USER}
Smtp__Password: ${SMTP_PASSWORD}
Smtp__FromAddress: ${SMTP_FROM}
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
start_period: 60s
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_DB: spindle
POSTGRES_USER: spindle
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U spindle -d spindle"]
interval: 5s
timeout: 5s
retries: 30
caddy:
image: caddy:2
restart: unless-stopped
command: caddy reverse-proxy --from ${DOMAIN} --to spindle:8080
ports:
- "80:80"
- "443:443"
volumes:
- caddy:/data
networks:
default:
ipam:
config:
- subnet: 172.28.0.0/24
volumes:
db:
caddy:.env
# The address people open, without https://
DOMAIN=time.example.com
# Make each one with: openssl rand -hex 32
POSTGRES_PASSWORD=
SIGNING_KEY=
# The mail server Spindle sends email from
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=spindle@example.com
SMTP_PASSWORD=
SMTP_FROM=spindle@example.comFill in .env, then start it:
docker compose up -d
On its first start, Spindle creates its tables in the database. Caddy gets a certificate for your domain on its own, so give it a minute, then open https://time.example.com.
Create your account and close sign-ups
-
Open your address and create an account. It comes with its own organization and workspace, and you are its admin.
-
Close sign-ups, so strangers can’t create accounts on your server. Add this line under
environmentof thespindleservice:Auth__AllowRegistration: "false"and apply it with
docker compose up -d. From then on, people join only through an invitation. -
Invite your team from the workspace. Each person gets an email with a link.
Connect the apps
The web app is at your address. In the Mac, Windows and Android apps, choose Your own server on the sign-in screen and enter the same address. On your own server, the apps sign in with email and password; passkeys in the apps work only with Spindle Cloud. The iPhone app from the App Store connects only to Spindle Cloud for now; see below.
Suggestions from Claude Code and Codex work the same as on Spindle Cloud: the activity stays on each person’s computer, and with activity sync on, it goes to your server and nowhere else. See How suggestions are made.
Settings
Spindle reads its settings from environment variables. A double underscore separates the parts of a name.
| Variable | What it is |
|---|---|
ConnectionStrings__Default |
The database, as Host=…;Port=5432;Database=…;Username=…;Password=…. It isn’t a postgres:// address. Use a password without ; in it, such as one from openssl rand -hex 32. |
Auth__SigningKey |
A random secret of at least 32 characters that signs sign-ins. Keep it secret, and keep it the same between restarts. |
App__BaseUrl |
The full address people open, with https://. Links in emails point here. |
AllowedHosts |
The host names Spindle answers to, separated by ;. Keep localhost;127.0.0.1 for the container’s health check. |
Auth__AllowRegistration |
true by default. With false, people join only through an invitation. |
ReverseProxy__KnownNetworks__0 |
The network your HTTPS proxy connects from, such as 172.28.0.0/24. Spindle trusts the proxy’s forwarded address and scheme only from there. Add more with __1, __2. |
Smtp__Host, Smtp__Port |
The mail server. The port is 587 if left out. |
Smtp__UserName, Smtp__Password |
Sign-in for the mail server, if it needs one. |
Smtp__Security |
Auto by default, which uses TLS when the server offers it. Also StartTls, SslOnConnect or None. |
Smtp__FromAddress, Smtp__FromName |
Who the email comes from. The name is Spindle if left out. |
Smtp__ReplyToAddress |
Where replies go, when nobody reads the sender address. |
App__TermsUrl, App__PrivacyUrl, App__TermsVersion |
Optional. With a terms address, new accounts have to agree to your terms; the version is kept with each account. |
Behind another proxy
Any HTTPS proxy works in place of Caddy, such as nginx, Traefik or the one in Coolify. It has to:
- forward to port 8080 of the
spindlecontainer; - pass WebSockets through, which keep the apps in sync live;
- send
X-Forwarded-ForandX-Forwarded-Proto, with its network inReverseProxy__KnownNetworks__0.
Find the network of a Docker proxy with:
docker network inspect NETWORK --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}'
Don’t use 0.0.0.0/0: Spindle would then trust any address a visitor claims.
Updating
docker compose pull
docker compose up -d
Spindle updates its database tables when it starts. Make a backup before you update.
Backups
Everything Spindle keeps is in the database. Back it up every day, and keep the copies away from the server:
docker compose exec -T db pg_dump -U spindle -d spindle -Fc > spindle-$(date +%F).dump
To restore, stop Spindle, restore the dump into an empty database, then start Spindle again:
docker compose stop spindle
docker compose exec -T db pg_restore -U spindle -d spindle --clean --if-exists --no-owner < spindle-2026-10-10.dump
docker compose start spindle
A backup you haven’t restored once is a guess, so try a restore into a spare database now and then.
Checking it
https://time.example.com/health answers Healthy when Spindle is running and can reach its database. Point your uptime monitor at it. To see what Spindle is doing:
docker compose logs -f spindle
Moving between Spindle Cloud and your own server
Export a detailed report as CSV from one, and import it into the other. Clients, projects, tasks, tags and time entries come along; people need to be invited first. See Importing.
How it differs from Spindle Cloud
Your own server has everything for tracking and dividing time: suggestions from Claude Code and Codex, splits, the MCP for agents, reports, exports, teams and the API. There is no trial, no plan page and no limit on people. You run the updates, the backups and the mail server; on Spindle Cloud, we do.
A few things depend on Spindle Cloud’s domain or on our credentials, and don’t work with your own server yet:
- The iPhone app from the App Store connects only to Spindle Cloud for now. To use it with your server, build it yourself with your server’s address. The web app works on a phone in the meantime.
- Passkeys in the apps. The released apps are signed for Spindle Cloud’s domain, so on your own server they sign in with email and password. Passkeys in the browser work on your server, over HTTPS.
- Push notifications on iPhone need the key of the team that signed the app. If you build the iPhone app yourself, set
Push__Apns__KeyId,Push__Apns__TeamId,Push__Apns__PrivateKeyandPush__Apns__BundleIdto use your own key.