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, standard Docker ops
All-in-OneDemos, small teamsSingle container with everything, simplest setup
Bootstrap ScriptFresh Ubuntu serversDocker Compose setup plus security hardening, automated

On a fresh Ubuntu server, the bootstrap script performs all of these steps for you. For a manual install:

# 1. Clone the deployment files git clone https://github.com/jfilter/timetiles.git cd timetiles/deployment # 2. Configure ./timetiles setup # Creates .env.production with a generated PAYLOAD_SECRET nano .env.production # Set at minimum: DOMAIN_NAME, DB_PASSWORD, LETSENCRYPT_EMAIL, RESTIC_PASSWORD, # and SMTP if registration stays enabled (see Configuration below) # 3. Write your domain into the nginx site config DOMAIN=your-domain.com sed -i "s/\${DOMAIN_NAME}/$DOMAIN/g" nginx/sites-enabled/*.conf # 4. Temporary self-signed certificate, so nginx can start before Let's Encrypt mkdir -p "ssl/live/$DOMAIN" openssl req -x509 -nodes -days 30 -newkey rsa:2048 -subj "/CN=$DOMAIN" \ -keyout "ssl/live/$DOMAIN/privkey.pem" -out "ssl/live/$DOMAIN/fullchain.pem" printf 'services:\n nginx:\n volumes:\n - ./ssl:/etc/letsencrypt:ro\n' \ > docker-compose.ssl-override.yml # 5. Pull images and start ./timetiles pull ./timetiles up # 6. Replace the temporary certificate (after DNS points to your server) ./timetiles ssl # Also removes docker-compose.ssl-override.yml # 7. Create your admin account ./timetiles create-admin you@example.com

The CLI is not on your PATH after a manual install. Run it as ./timetiles from deployment/, or link it: sudo ln -sf "$PWD/timetiles" /usr/local/bin/timetiles. The remaining examples call it as timetiles.

timetiles update resets the tracked nginx site files and substitutes DOMAIN_NAME again (see Updating).

Multiple Web Processes

The Compose file assigns fixed container names, so docker compose --scale does not work without an override that removes them. 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.

Each web process caches site and view settings for up to five minutes. A change saved through one process reaches the others when that cache expires, so a site setting such as branding or the default layout template can take up to five minutes to show everywhere.

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) \ -e EMAIL_DISABLED=true \ ghcr.io/jfilter/timetiles:latest-allinone

The app refuses to start without mail settings. EMAIL_DISABLED=true runs without outgoing mail, so signup, password reset and email change are unavailable; to send mail, set EMAIL_SMTP_HOST and EMAIL_FROM instead.

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.

The container generates a self-signed certificate on first start. To serve a real one, place fullchain.pem and privkey.pem in /data/ssl and restart the container. The all-in-one image has no Let’s Encrypt automation, so renew and replace these files yourself.

For outgoing email (needed for public registration, see Configuration), pass the EMAIL_* variables with -e as well.

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)

Fully automated setup on a fresh Ubuntu 22.04 or later server (24.04 LTS recommended). 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. It links the CLI to /usr/local/bin/timetiles, installs restic and generates RESTIC_PASSWORD, and installs the cron jobs for backups, health checks, and certificate reloads.

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
RESTIC_PASSWORDBackup encryption password; timetiles backup refuses to run without it

Email (required: set either EMAIL_SMTP_HOST or EMAIL_DISABLED=true):

VariableDescription
EMAIL_SMTP_HOSTSMTP server
EMAIL_SMTP_PORTSMTP port (default 587)
EMAIL_SMTP_USER / EMAIL_SMTP_PASSSMTP credentials, if the server requires them
EMAIL_FROMSender address
EMAIL_SMTP_REQUIRE_TLSfalse allows plaintext SMTP (default true)
EMAIL_DISABLEDtrue runs without outgoing mail

Self-registered users must confirm their email address before they can sign in. With EMAIL_DISABLED=true, registration, password reset and email change answer 503; turn off Enable Public Registration in the admin dashboard and create accounts yourself. The bootstrap installer writes the SMTP settings from bootstrap.conf, or EMAIL_DISABLED=true when none are set.

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

The certbot container checks for renewal every 12 hours. nginx only serves a renewed certificate after a reload: bootstrap installs a daily nginx -s reload cron job, and a manual install needs its own, for example:

0 6 * * * cd /path/to/timetiles/deployment && docker compose -f docker-compose.prod.yml --env-file .env.production exec -T nginx nginx -s reload

Custom Certificates

The default nginx configuration reads certificates from /etc/letsencrypt/live/${DOMAIN_NAME}/ inside the container, supplied by the letsencrypt volume. There is no automatic mount for a host deployment/nginx/ssl/ directory.

For custom certificates, configure a read-only bind mount in a Compose override and set nginx’s ssl_certificate and ssl_certificate_key paths to match. Supply both:

  • 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 setupCreate .env.production from the template with a fresh secret
timetiles buildBuild images locally if an override exists, otherwise pull them
timetiles pullPull images without restarting
timetiles upStart all services
timetiles downStop all services
timetiles restart [service]Restart all services, or one
timetiles statusCheck service health
timetiles checkVerify the whole deployment (config, services, SSL, DNS, cron)
timetiles logsFollow all container logs, merged with the scraper runner
timetiles logs --webFollow the web container only
timetiles logs --scraperFollow the scraper runner (journald)
timetiles logs --filesFollow the log files in /var/log/timetiles/
timetiles updateRefresh deployment files and images, then redeploy
timetiles sslObtain a Let’s Encrypt certificate
timetiles create-admin <email>Create an admin account
timetiles backupCreate a backup (see Maintenance)
timetiles restoreRestore from backup
timetiles versionShow the installed version

For a single service other than web, use Docker Compose directly (below).

Direct Docker Compose

For advanced usage:

# Run from the deployment directory (/opt/timetiles on bootstrapped hosts) alias dc="docker compose -f docker-compose.prod.yml --env-file .env.production" dc ps # List containers dc logs -f worker-ingest # Follow one service's logs dc exec web sh # Shell into web container

The CLI also layers docker-compose.override.yml and docker-compose.ssl-override.yml when they exist; the alias does not, so use it for inspection and exec, and the CLI to start or recreate services.

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. Operator state is .gitignored inside deployment/ and survives every update; see Updating for what happens to tracked files.

/opt/timetiles-src/ ├── .git/ # sparse-checkout: deployment/, apps/web/config/ (+ scraper sources) └── 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 keeps the deployment/ directory in your repo checkout, with 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
  • Import-wizard previews use a separate tmpfs volume shared only by web and the maintenance worker, so abandoned previews can be removed every six hours. It is writable only by the app user (UID/GID 1001) and is not persistent storage: unmounting the volume or restarting the Docker host discards active previews.
  • 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","database":"connected"} (503 when the database is unreachable)

Updating

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

timetiles update fetches the deployment files for the configured version and runs git reset --hard on the checkout, which discards local edits to tracked files (compose, nginx, the CLI). Keep your changes in docker-compose.override.yml and .env.production, which are ignored by git. It then pulls the images, recreates the services, and reloads nginx.

The version comes from TIMETILES_VERSION in .env.production. Set it to a release such as 1.0.0 to pin both the images and the deployment files to the v1.0.0 tag; with latest or edge, the deployment files follow the main branch.

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