Skip to Content
⚠️Active Development Notice: TimeTiles is under active development. Information may be placeholder content or not up-to-date.
Self-HostingDeployment

Deployment

Three ways to deploy TimeTiles to production. All include PostgreSQL, Nginx with SSL, and background workers.

Deployment Options

OptionBest forWhat you get
Docker ComposeProductionSeparate containers, horizontal scaling, standard Docker ops
All-in-OneDemos, small teamsSingle container with everything, simplest setup
Bootstrap ScriptFresh Ubuntu serversAutomated setup including security hardening
# 1. Clone the deployment files git clone https://github.com/jfilter/timetiles.git cd timetiles # 2. Configure cp deployment/.env.production.example deployment/.env.production nano deployment/.env.production # Set at minimum: DOMAIN_NAME, DB_PASSWORD, PAYLOAD_SECRET, LETSENCRYPT_EMAIL # 3. Pull images and start timetiles pull timetiles up # 4. Set up SSL (after DNS is pointing to your server) timetiles ssl # 5. Create your admin account timetiles create-admin you@example.com

Horizontal Web Scaling

If you run more than one web container or otherwise serve overlapping traffic from multiple Node.js processes, set:

RATE_LIMIT_BACKEND=pg

The default memory backend is only correct for single-web-instance deployments. In a multi-worker setup, leaving rate limits in memory would multiply the effective limit across workers.

All-in-One Container

Everything in a single container — PostgreSQL, Nginx, and the app — managed by supervisord. Good for quick demos or personal use.

docker run -d --name timetiles \ -p 80:80 -p 443:443 \ -v timetiles-data:/data \ -e PAYLOAD_SECRET=$(openssl rand -base64 32) \ -e DB_PASSWORD=$(openssl rand -hex 24) \ ghcr.io/jfilter/timetiles:latest-allinone

DB_PASSWORD must not contain @ : / % + or other URI-special characters. The container builds its DATABASE_URL by interpolating the password into postgresql://user:PASSWORD@host/db without percent-encoding it, so one of those characters silently produces a malformed connection string and the app fails to reach its own database. That is why the command above uses openssl rand -hex, not openssl rand -base64 — base64 output is drawn from an alphabet that includes / and +. If you prefer base64, strip them: openssl rand -base64 32 | tr -d '/+='.

PAYLOAD_SECRET is never interpolated into a URL, so base64 is fine there.

Then create your admin account — see First Admin Account.

Persistent Data

The /data volume is the only thing worth backing up. Everything that must outlive a container recreate lives there:

PathContents
/data/postgresqlDatabase cluster
/data/uploadsUploaded source files
/data/exportsGenerated data exports
/data/configOptional timetiles.yml override (see below)
/data/sslTLS certificate and key

Configuration Overrides

The container sets sensible defaults for a single-host install; pass -e to override any of them:

VariableDefaultWhen to change it
TRUSTED_PROXY_CIDRS127.0.0.1/32,::1/128Another proxy or CDN sits in front of this container — add the CIDR it forwards from
LOG_LEVELinfoSet debug while troubleshooting
DEPLOYMENT_ENVIRONMENTproductionSet staging or preview to show the environment banner
NEXT_PUBLIC_PAYLOAD_URLhttp://localhostSet to your public URL

TRUSTED_PROXY_CIDRS decides which forwarded IPs the app believes. The default covers the bundled Nginx, which reaches the app over loopback. If you put another proxy in front and do not extend this, every visitor shares one rate-limit bucket.

For the tunables in timetiles.yml (rate limits, quotas, batch sizes), drop the file into the volume — the app picks it up on restart:

# Start from the template shipped in the image docker cp timetiles:/app/apps/web/config/timetiles.example.yml ./timetiles.yml docker cp ./timetiles.yml timetiles:/data/config/timetiles.yml docker restart timetiles

See Configuration for the full reference.

Logs

All services log to the container’s stdout, so docker logs is the single place to look and your Docker log driver handles rotation:

docker logs -f timetiles

Bootstrap Script (Ubuntu 24.04)

Fully automated setup on a fresh Ubuntu server. Installs Docker, configures firewall, deploys TimeTiles, sets up SSL, and enables security hardening (fail2ban, SSH hardening).

curl -sSL https://raw.githubusercontent.com/jfilter/timetiles/main/deployment/bootstrap/install.sh | sudo bash

The script prompts for your domain, email, and other settings interactively.

First Admin Account

A fresh installation has no user accounts. Create the first one before signing in.

Docker Compose and bootstrap installs:

timetiles create-admin you@example.com

All-in-one container — the timetiles CLI drives Docker Compose and cannot reach it, so run the equivalent helper inside the container:

docker exec -it timetiles /app/create-admin.sh you@example.com

Both ask for a password twice and create a verified admin account. Both refuse if an account with that address already exists, so neither can be used to change an existing user’s password or role. -it matters: without it there is no terminal to read the password from. For unattended setups, pass the password as TIMETILES_ADMIN_PASSWORD instead of typing it.

Self-registration through the web interface always creates a regular user, never an admin — these commands are the only way to create one.

Configuration

Create your environment file from the template:

cp deployment/.env.production.example deployment/.env.production

Required settings:

VariableDescription
DOMAIN_NAMEYour production domain
DB_PASSWORDDatabase password (20+ chars, no @ : / % + — see note above; openssl rand -hex 24)
PAYLOAD_SECRETRandom secret (openssl rand -base64 32)
LETSENCRYPT_EMAILEmail for SSL certificate notifications

Shared-rate-limit setting for multi-web deployments:

VariableDescription
RATE_LIMIT_BACKENDSet to pg when more than one web process/container serves overlapping traffic

See Configuration for the full environment variable reference.

SSL/TLS

After DNS is pointing to your server and services are running:

timetiles ssl

Certificates auto-renew every 12 hours via the certbot container.

Custom Certificates

Place your certificate files in deployment/nginx/ssl/:

  • fullchain.pem — certificate + chain
  • privkey.pem — private key

Then restart nginx: timetiles restart nginx

CLI Reference

The timetiles CLI wraps Docker Compose for common operations:

CommandWhat it does
timetiles upStart all services
timetiles downStop all services
timetiles restartRestart services
timetiles statusCheck service health
timetiles logsView logs (add -f to follow)
timetiles updatePull latest images and redeploy
timetiles pullPull images without restarting
timetiles sslInitialize Let’s Encrypt certificates
timetiles backupCreate a backup (see Maintenance)
timetiles restoreRestore from backup

Direct Docker Compose

For advanced usage:

alias dc="docker compose -f deployment/docker-compose.prod.yml --env-file deployment/.env.production" dc ps # List containers dc logs web # View web logs dc exec web sh # Shell into web container

File Structure

A bootstrapped host keeps the deployment files in a real git working tree at /opt/timetiles-src/ and exposes them as /opt/timetiles/ via a symlink. timetiles update runs git pull against that tree, so tracked files (compose, nginx, the CLI itself) stay in sync with the upstream branch. Operator state is .gitignored inside deployment/ and survives every pull.

/opt/timetiles-src/ ├── .git/ # sparse-checkout: deployment/ └── deployment/ ├── timetiles # CLI script (tracked) ├── docker-compose.prod.yml # Service orchestration (tracked) ├── nginx/ # Nginx config (tracked) │ ├── nginx.conf │ └── sites-enabled/ ├── .env.production # Your configuration (.gitignored) ├── docker-compose.override.yml # Optional override (.gitignored) └── backups/ # Backup files (.gitignored) /opt/timetiles -> /opt/timetiles-src/deployment # compat symlink

A manual install just keeps the deployment/ directory in your repo checkout and there is no symlink.

Security

  • Only ports 80 and 443 are exposed externally
  • PostgreSQL is on an internal Docker network — not accessible from outside
  • The Next.js app runs as a non-root user in a read-only container
  • Containers have dropped capabilities and tmpfs /tmp
  • Nginx enforces HTTPS and sets security headers
  • Auth endpoints are rate-limited at the Nginx level (5 req/min per IP)

Health Checks

# CLI status check timetiles status # HTTP health endpoint curl https://your-domain.com/api/health # Returns: {"status":"ok","timestamp":"..."}

Updating

# 1. Back up first timetiles backup # 2. Pull and redeploy timetiles update # 3. Verify curl https://your-domain.com/api/health

Database migrations run automatically on container startup.

Troubleshooting

ProblemSolution
Build fails with OOMAdd 4 GB swap: sudo fallocate -l 4G /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
SSL certificate failsVerify DNS resolves to your server: dig your-domain.com +short
App won’t startCheck logs: timetiles logs web
Database connection errorCheck postgres is running: timetiles status
Port already in useStop existing services: timetiles down

Optional: Scraper Runner

If you need scrapers (custom Python/Node.js scripts for data extraction), deploy the TimeScrape runner alongside TimeTiles. The runner is a separate stateless service that executes scripts in hardened Podman containers.

See Scraper Deployment for full setup instructions. In short:

  1. Install Podman (rootless mode)
  2. Build the base container images
  3. Start the runner service
  4. Set SCRAPER_RUNNER_URL and SCRAPER_API_KEY in your .env.production
  5. Enable the enableScrapers feature flag in the admin dashboard

Next Steps

Last updated on