Deployment
Three ways to deploy TimeTiles to production. All include PostgreSQL, Nginx with SSL, and background workers.
Deployment Options
| Option | Best for | What you get |
|---|---|---|
| Docker Compose | Production | Separate containers, standard Docker ops |
| All-in-One | Demos, small teams | Single container with everything, simplest setup |
| Bootstrap Script | Fresh Ubuntu servers | Docker Compose setup plus security hardening, automated |
Docker Compose (Recommended)
Quick Start
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.comThe 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=pgThe 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-allinoneThe 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:
| Path | Contents |
|---|---|
/data/postgresql | Database cluster |
/data/uploads | Uploaded source files |
/data/exports | Generated data exports |
/data/config | Optional timetiles.yml override (see below) |
/data/ssl | TLS certificate and key |
Configuration Overrides
The container sets sensible defaults for a single-host install; pass -e to
override any of them:
| Variable | Default | When to change it |
|---|---|---|
TRUSTED_PROXY_CIDRS | 127.0.0.1/32,::1/128 | Another proxy or CDN sits in front of this container — add the CIDR it forwards from |
LOG_LEVEL | info | Set debug while troubleshooting |
DEPLOYMENT_ENVIRONMENT | production | Set staging or preview to show the environment banner |
NEXT_PUBLIC_PAYLOAD_URL | http://localhost | Set 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 timetilesSee 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 timetilesBootstrap 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 bashThe 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.comAll-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.comBoth 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.productionRequired settings:
| Variable | Description |
|---|---|
DOMAIN_NAME | Your production domain |
DB_PASSWORD | Database password (20+ chars, no @ : / % + — see note above; openssl rand -hex 24) |
PAYLOAD_SECRET | Random secret (openssl rand -base64 32) |
LETSENCRYPT_EMAIL | Email for SSL certificate notifications |
RESTIC_PASSWORD | Backup encryption password; timetiles backup refuses to run without it |
Email (required: set either EMAIL_SMTP_HOST or EMAIL_DISABLED=true):
| Variable | Description |
|---|---|
EMAIL_SMTP_HOST | SMTP server |
EMAIL_SMTP_PORT | SMTP port (default 587) |
EMAIL_SMTP_USER / EMAIL_SMTP_PASS | SMTP credentials, if the server requires them |
EMAIL_FROM | Sender address |
EMAIL_SMTP_REQUIRE_TLS | false allows plaintext SMTP (default true) |
EMAIL_DISABLED | true 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:
| Variable | Description |
|---|---|
RATE_LIMIT_BACKEND | Set to pg when more than one web process/container serves overlapping traffic |
See Configuration for the full environment variable reference.
SSL/TLS
Let’s Encrypt (Recommended)
After DNS is pointing to your server and services are running:
timetiles sslThe 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 reloadCustom 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 + chainprivkey.pem— private key
Then restart nginx: timetiles restart nginx
CLI Reference
The timetiles CLI wraps Docker Compose for common operations:
| Command | What it does |
|---|---|
timetiles setup | Create .env.production from the template with a fresh secret |
timetiles build | Build images locally if an override exists, otherwise pull them |
timetiles pull | Pull images without restarting |
timetiles up | Start all services |
timetiles down | Stop all services |
timetiles restart [service] | Restart all services, or one |
timetiles status | Check service health |
timetiles check | Verify the whole deployment (config, services, SSL, DNS, cron) |
timetiles logs | Follow all container logs, merged with the scraper runner |
timetiles logs --web | Follow the web container only |
timetiles logs --scraper | Follow the scraper runner (journald) |
timetiles logs --files | Follow the log files in /var/log/timetiles/ |
timetiles update | Refresh deployment files and images, then redeploy |
timetiles ssl | Obtain a Let’s Encrypt certificate |
timetiles create-admin <email> | Create an admin account |
timetiles backup | Create a backup (see Maintenance) |
timetiles restore | Restore from backup |
timetiles version | Show 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 containerThe 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 symlinkA 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/healthtimetiles 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
| Problem | Solution |
|---|---|
| Build fails with OOM | Add 4 GB swap: sudo fallocate -l 4G /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile |
| SSL certificate fails | Verify DNS resolves to your server: dig your-domain.com +short |
| App won’t start | Check logs: timetiles logs --web |
| Database connection error | Check postgres is running: timetiles status |
| Port already in use | Stop 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:
- Install Podman (rootless mode)
- Build the base container images
- Start the runner service
- Set
SCRAPER_RUNNER_URLandSCRAPER_API_KEYin your.env.production - Enable the
enableScrapersfeature flag in the admin dashboard
Next Steps
- Configure environment variables, geocoding, and feature flags
- Set up backups and monitoring
- Customize the UI with themes and branding