Run it on boxes you own
Two compose files and the same four images. Start with one app container beside one Postgres, then split the tiers out as traffic asks - by changing env, not by rebuilding.
- Needs
- Docker
- a domain
- a bucket, or a directory
Pick a shape
Two compose files, the same images in both. Every service binds to loopback, so something sits in front and terminates TLS.
- The small shape is commerce only - no Valkey, no ClickHouse, no Signals - and its ports are offset to run beside the dev stack
- The full shape splits the tiers into their own containers, with a pool per database and three Valkeys
- The datastores sit behind the
bundledprofile - on by default, off for managed ones - Object storage is the one thing in neither file: S3 or anything that speaks it, or a directory on the box
| Service | Image | Healthcheck | Published |
|---|---|---|---|
| App | |||
| app | skelet-app | :8080/health | 127.0.0.1:18080 |
| migrate | skelet-app | runs once | in-network |
| Frontends | |||
| dashboard | skelet-dashboard | :8080/healthz | 127.0.0.1:18081 |
| vetrina-demo | skelet-vetrina-demo | :3000/api/healthz | 127.0.0.1:13000 |
| Datastore | |||
| db | postgres:18-alpine | pg_isready | 127.0.0.1:15432 |
| Optional | |||
| caddy--profile edge | caddy:2-alpine | :2019/config/ | :80, :443 |
| Service | Image | Healthcheck | Published | Limits |
|---|---|---|---|---|
| Tiers | ||||
| app | skelet-app | :8080/health | 127.0.0.1:8080 | 1g · 2 cpu |
| worker | skelet-app | :8081/healthz | in-network | 512m · 2 cpu |
| media | skelet-app | :8083/healthz | 127.0.0.1:8083 | 512m · 2 cpu |
| signals | skelet-signals | :8090/healthz | in-network | 256m · 1 cpu |
| ingest | skelet-signals | :8091/healthz | 127.0.0.1:8091 | 512m · 2 cpu |
| Run once, before the tiers | ||||
| migrate | skelet-app | runs once | in-network | - |
| signals-migrate | skelet-signals | runs once | in-network | - |
| clickhouse-migrate | skelet-signals | runs once | in-network | - |
| Frontends | ||||
| dashboard | skelet-dashboard | :8080/healthz | 127.0.0.1:8081 | 128m · 0.5 cpu |
| vetrina-demo | skelet-vetrina-demo | :3000/api/healthz | 127.0.0.1:3000 | 512m · 1 cpu |
| Datastores--profile bundled | ||||
| pgbouncer | edoburu/pgbouncer:v1.25.2-p0 | pg_isready :6432 | in-network | 128m · 1 cpu |
| pgbouncer-signals | edoburu/pgbouncer:v1.25.2-p0 | pg_isready :6432 | in-network | 128m · 1 cpu |
| db | postgres:18-alpine | pg_isready | in-network | 1g · 2 cpu |
| signals-db | postgres:18-alpine | pg_isready | in-network | 1g · 2 cpu |
| valkey | valkey/valkey:9-alpine | valkey-cli ping | in-network | 256m · 0.5 cpu |
| valkey-cache | valkey/valkey:9-alpine | valkey-cli ping | in-network | 640m · 0.5 cpu |
| valkey-limits | valkey/valkey:9-alpine | valkey-cli ping | in-network | 96m · 0.25 cpu |
| clickhouse | clickhouse/clickhouse-server:26.7.5 | SELECT 1 | in-network | 4g · 4 cpu |
| Optional | ||||
| caddy--profile edge | caddy:2-alpine | :2019/config/ | :80, :443 | 128m · 1 cpu |
| nats--profile queue | nats:2.14-alpine | :8222/healthz | in-network | 256m · 1 cpu |
Fill in the env
Secrets go in the root .env - the only file compose interpolates, so a password used in two places is written once. Everything else goes to one container through its own file.
./skelet setupgenerates the session and link keys- Every secret two tiers share has to differ from every other one
CONFIG_ENCRYPTION_KEYstays put - rotating it orphans every stored secret- Production refuses to boot on an empty, short or dev-default secret
- # Self-hosted: the bundled Postgres, pgbouncer, Valkey and ClickHouse containers
- COMPOSE_PROFILES=bundled
- # openssl rand -hex 32 - admin sessions, core alone
- ADMIN_JWT_SECRET=••••••••••••
- # Customer sessions, Ed25519 - core signs with the private key
- CUSTOMER_JWT_PRIVATE_KEY=••••••••••••
- CUSTOMER_JWT_PUBLIC_KEY=••••••••••••
- # openssl rand -hex 32 - unsubscribe, open and click links
- EMAIL_LINK_SECRET=••••••••••••
- REVIEW_VOTER_TOKEN_SECRET=••••••••••••
- SIGNALS_SERVICE_SECRET=••••••••••••
- # openssl rand -base64 32, keep stable
- CONFIG_ENCRYPTION_KEY=••••••••••••
- SIGNALS_ENCRYPTION_KEY=••••••••••••
- DB_PASSWORD=••••••••••••
- SIGNALS_DB_PASSWORD=••••••••••••
- VALKEY_PASSWORD=••••••••••••
- CLICKHOUSE_PASSWORD=••••••••••••
- # The hosts browsers upload files to - the S3 presign host, or STORAGE_UPLOAD_URL
- UPLOAD_ORIGINS=https://media-bucket.s3.eu-central-1.amazonaws.com
| File | Read by |
|---|---|
| .env | docker compose itself - the secrets, and the URLs built from them |
| env/prod.env | app, worker, media, signals, ingest and migrate - one shared file |
| env/prod-signals.env | signals alone, on top of prod.env - optional |
| env/prod-vetrina-demo.env | the storefront |
| env/prod-caddy.env | the opt-in Caddy edge - its domains and the ACME email |
| env/small.env | the small shape's one app container |
| env/small-vetrina-demo.env | its storefront |
{"time":"2026-10-25T09:31:02.118Z","level":"ERROR","msg":"production config error","issue":"TRUSTED_PROXIES must be set in production: your proxy/LB CIDRs (so forwarded client IPs are trusted for rate limiting), or the literal 'none' if the backend is directly internet-facing"}{"time":"2026-10-25T09:31:02.118Z","level":"ERROR","msg":"production config error","issue":"ADMIN_JWT_SECRET and EMAIL_LINK_SECRET must differ - they are held by different tiers, and one value would hand over both"}{"time":"2026-10-25T09:31:02.118Z","level":"ERROR","msg":"production config error","issue":"CORS_ALLOWED_ORIGINS must list the deployed frontend origins (empty blocks all browser API calls)"}{"time":"2026-10-25T09:31:02.119Z","level":"ERROR","msg":"server exited with error","err":"refusing to start: 3 production config error(s) - see logs above"}Put an edge in front
Three hostnames, whoever terminates TLS. Bring nginx, Traefik or a load balancer - or switch on the bundled Caddy for Let's Encrypt certificates and their renewal.
- HSTS belongs at the edge and only there - the app and the dashboard leave it off their own headers
- Skip the
api.host and payment captures never settle - One storefront container serves every channel - add each host to
STOREFRONT_CHANNEL_HOSTS - Keep the storefront to one replica: its page cache lives in the process
TRUSTED_PROXIESnames what's in front, or every per-IP limit shares one bucket
- shop.example.comvetrina-demo:3000The storefront - every channel's host can point at the same upstream
- admin.example.comdashboard:8080The admin - its own nginx proxies
/api/*to the backend in-network, so it needs no API host - api.example.comapp:8080Inbound payment webhooks - providers POST to
/api/v1/payments/webhook/<provider>, the URL you register with them - api.example.comingest:8091The beacons, once the ingest tier is scaled out -
appproxies them on otherwise - api.example.commedia:8083Image transforms, likewise -
appserves the route either way
$ cp env/prod-caddy.env.example env/prod-caddy.env# domains + ACME email$ docker compose -f docker-compose.small.yml --profile edge up -d{$STOREFRONT_DOMAIN:localhost} { import security_headers encode zstd gzip reverse_proxy vetrina-demo:3000}{$API_DOMAIN:api.localhost} { import security_headers encode zstd gzip # reverse_proxy /media/t/* media:8083 # reverse_proxy /t/* ingest:8091 reverse_proxy app:8080}Build the images
A release builds four images and pushes them to your registry. Every service in either shape runs one of them, or a stock datastore image.
- Built for
linux/amd64unless you say otherwise, so a Mac never ships arm64 to an amd64 box - Storefronts are discovered - a fork's own shop is built without editing the script
UPLOAD_ORIGINSis baked into the storefront at build, from your shell or the root .env- The tag defaults to the commit's short sha
skelet-app
- Built from
- apps/backend, core/Dockerfile.prod
- Read as
- APP_IMAGE
- app
- worker
- media
- migrate
skelet-signals
- Built from
- apps/backend, signals/Dockerfile.prod
- Read as
- SIGNALS_IMAGE
- signals
- ingest
- signals-migrate
- clickhouse-migrate
skelet-dashboard
- Built from
- apps/dashboard
- Read as
- DASHBOARD_IMAGE
- dashboard
skelet-vetrina-demo
- Built from
- apps/channels/vetrina-demo
- Read as
- VETRINA_DEMO_IMAGE
- vetrina-demo
One per storefront under apps/channels/ with a Dockerfile.prod
$ REGISTRY=registry.example.com ./skelet release v0.1.0==> build registry.example.com/skelet-app:v0.1.0 (linux/amd64)==> push registry.example.com/skelet-app:v0.1.0==> build registry.example.com/skelet-signals:v0.1.0 (linux/amd64)==> push registry.example.com/skelet-signals:v0.1.0==> build registry.example.com/skelet-dashboard:v0.1.0 (linux/amd64)==> push registry.example.com/skelet-dashboard:v0.1.0==> build registry.example.com/skelet-vetrina-demo:v0.1.0 (linux/amd64)==> push registry.example.com/skelet-vetrina-demo:v0.1.0Pushed tag: v0.1.0Deploy with (docker-compose.prod.yml reads these): export APP_IMAGE=registry.example.com/skelet-app:v0.1.0 export SIGNALS_IMAGE=registry.example.com/skelet-signals:v0.1.0 export DASHBOARD_IMAGE=registry.example.com/skelet-dashboard:v0.1.0 export VETRINA_DEMO_IMAGE=registry.example.com/skelet-vetrina-demo:v0.1.0 docker compose -f docker-compose.prod.yml up -dGrow a switch at a time
Everything works in one process; splitting buys isolation. Each step is a line of env or a route at the edge - the images never change.
- Postgres-only is a fair choice for one node - caches go quiet, everything else keeps working
JOBS_EXTERNAL=truewith no worker running stops background work entirely- The media and ingest tiers serve the same paths
appdoes, so routing them is a scaling choice
start
nothing setOne app container runs every backend role - the API, websockets, media transforms, the scheduler, webhook delivery and the outbox relay - beside one Postgres.
- app
- db
env
VALKEY_URL=redis://…Caches, rate limits and lockouts shared across processes, notifications that reach every replica, and draft previews - the one feature that needs it. Keep it once there is more than one replica.
- app ×2
- db
- valkey
env
OPENSEARCH_URL=https://…Tuned relevance, stemming, merch boosts and facets on every attribute. Without it, browse runs on Postgres - slower on a big catalogue, never empty.
- app ×2
- db
- valkey
- opensearch
env
JOBS_EXTERNAL=trueThe app stops claiming scheduler jobs, webhook deliveries and outbox rows, and the worker takes them. They are claimed through the database, so worker replicas split the work.
- app ×2
- db
- valkey
- opensearch
- worker
edge
/media/t/* → media:8083Image decode and resize get a CPU budget of their own. Warm images redirect to the bucket, so at steady state the tier mostly idles.
- app ×2
- db
- valkey
- opensearch
- worker
- media
env
SIGNALS_ENABLED=trueThe Signals suite, in the full shape: a process of its own, its own Postgres, and ClickHouse for the event spine.
- app ×2
- db
- valkey
- opensearch
- worker
- media
- signals
- signals-db
- clickhouse
edge
/t/* → ingest:8091A spike on the beacons saturates this tier instead of checkout. It keeps no state, so scale it freely.
- app ×2
- db
- valkey
- opensearch
- worker
- media
- signals
- signals-db
- clickhouse
- ingest
Or bring managed datastores
Drop one line and point the URLs outward. The bundled datastores never start, instead of idling beside the ones you're paying for - and everything else is the same image and the same file.
- All or nothing: with
bundledstill on, every bundled container starts and refuses an empty password sslmode=disablerefuses to boot - managed providers wantrequireorverify-fullanyway- The spine's migrate one-shot runs against whatever
SIGNALS_CH_URLnames - Frontends on a platform like Vercel make
CORS_ALLOWED_ORIGINSrequired, andTRUSTED_PROXIEShas to name what's in front
- COMPOSE_PROFILES=bundled
- DATABASE_URL=postgres://…@your-managed-pg:5432/skelet?sslmode=require
- SIGNALS_DB_URL=postgres://…@your-managed-pg:5432/skelet_signals?sslmode=require
- VALKEY_URL=rediss://…
- SIGNALS_CH_URL=clickhouse://user:password@your-managed-ch:9440/signals?secure=true
Never start 8
- db
- signals-db
- pgbouncer
- pgbouncer-signals
- valkey
- valkey-cache
- valkey-limits
- clickhouse
Same image, same file 10
- app
- worker
- media
- signals
- ingest
- migrate
- signals-migrate
- clickhouse-migrate
- dashboard
- vetrina-demo
Watch it run
Every tier answers a health endpoint the compose files already check, gets time to drain before it stops, and writes structured JSON to stdout for your own stack to ship.
- Logs are capped at 10 MB × 3 per service - that cap is the retention, so ship them if you need history
- OpenTelemetry traces and metrics once the
OTEL_*vars are set, Sentry once there is a DSN - Overdue scheduled jobs show on the health page in the admin
- app
/healthPings Postgres and Valkey40s to drain10s HTTP, 10s background workers, 15s event bus - app
/readyzAnswers 503 while optional pieces are unconfigured- - worker
:8081/healthzScheduler jobs, webhook delivery, the outbox relay35s to drainKilled early, in-flight jobs wait out their lease - media
:8083/healthzImage resize and focal crop20s to drain10s draining, then 5s flushing telemetry - signals
:8090/healthzRetention, forwarding, trait compute35s to drainA retention prune won't finish inside 10s - ingest
:8091/healthzThe public /t beacons20s to drain10s stopping, then 5s flushing telemetry
$ sh -c "dbmate --no-dump-schema --wait --migrations-dir /app/migrations up && skelet plugin migrate up && skelet data-patch up"# the schema, every enabled plugin's migrations, then the run-once data patches - app waits for it to exit 0Back it up yourself
skelet takes no database backups, on purpose: a dump from inside the app works for a small store and quietly stops working as it grows. That's your infrastructure's job, done with tools built for it.
- On managed Postgres, point-in-time recovery is a checkbox
- Self-hosting, pgBackRest or wal-g - a base backup, then the write-ahead log archived continuously
- Restore both Postgres databases from the same point in time
- The
BACKUP_*variables are the analytics archive, not database backups
| What | Holds | How |
|---|---|---|
| Commerce Postgres | Orders, customers, catalog - everything that matters | Point-in-time recovery |
| Signals Postgres | Campaigns, flows, segments, the send log | Point-in-time recovery |
| ClickHouse spine | Raw analytics events, Lens sessions | BACKUP DATABASE signals TO S3(...) |
| Object storage | Media, and Lens replay blobs | Bucket replication or versioning, or a snapshot of STORAGE_DIR |
| OpenSearch | Rebuilt from Postgres | ./skelet cli search reindex |
| Valkey | Caches and sessions | Nothing to keep |
