Deploy

Self-hosted

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

Shape

Step 01 of 08

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 bundled profile - 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
docker-compose.small.yml6 services · 5 images
ServiceImageHealthcheckPublished
App
appskelet-app:8080/health127.0.0.1:18080
migrateskelet-appruns oncein-network
Frontends
dashboardskelet-dashboard:8080/healthz127.0.0.1:18081
vetrina-demoskelet-vetrina-demo:3000/api/healthz127.0.0.1:13000
Datastore
dbpostgres:18-alpinepg_isready127.0.0.1:15432
Optional
caddy--profile edgecaddy:2-alpine:2019/config/:80, :443
docker-compose.prod.yml20 services · 10 images
ServiceImageHealthcheckPublishedLimits
Tiers
appskelet-app:8080/health127.0.0.1:80801g · 2 cpu
workerskelet-app:8081/healthzin-network512m · 2 cpu
mediaskelet-app:8083/healthz127.0.0.1:8083512m · 2 cpu
signalsskelet-signals:8090/healthzin-network256m · 1 cpu
ingestskelet-signals:8091/healthz127.0.0.1:8091512m · 2 cpu
Run once, before the tiers
migrateskelet-appruns oncein-network-
signals-migrateskelet-signalsruns oncein-network-
clickhouse-migrateskelet-signalsruns oncein-network-
Frontends
dashboardskelet-dashboard:8080/healthz127.0.0.1:8081128m · 0.5 cpu
vetrina-demoskelet-vetrina-demo:3000/api/healthz127.0.0.1:3000512m · 1 cpu
Datastores--profile bundled
pgbounceredoburu/pgbouncer:v1.25.2-p0pg_isready :6432in-network128m · 1 cpu
pgbouncer-signalsedoburu/pgbouncer:v1.25.2-p0pg_isready :6432in-network128m · 1 cpu
dbpostgres:18-alpinepg_isreadyin-network1g · 2 cpu
signals-dbpostgres:18-alpinepg_isreadyin-network1g · 2 cpu
valkeyvalkey/valkey:9-alpinevalkey-cli pingin-network256m · 0.5 cpu
valkey-cachevalkey/valkey:9-alpinevalkey-cli pingin-network640m · 0.5 cpu
valkey-limitsvalkey/valkey:9-alpinevalkey-cli pingin-network96m · 0.25 cpu
clickhouseclickhouse/clickhouse-server:26.7.5SELECT 1in-network4g · 4 cpu
Optional
caddy--profile edgecaddy:2-alpine:2019/config/:80, :443128m · 1 cpu
nats--profile queuenats:2.14-alpine:8222/healthzin-network256m · 1 cpu

Env

Step 02 of 08

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 setup generates the session and link keys
  • Every secret two tiers share has to differ from every other one
  • CONFIG_ENCRYPTION_KEY stays put - rotating it orphans every stored secret
  • Production refuses to boot on an empty, short or dev-default secret
.envfrom .env.example · secrets masked
  1. # Self-hosted: the bundled Postgres, pgbouncer, Valkey and ClickHouse containers
  2. COMPOSE_PROFILES=bundled
  3. # openssl rand -hex 32 - admin sessions, core alone
  4. ADMIN_JWT_SECRET=••••••••••••
  5. # Customer sessions, Ed25519 - core signs with the private key
  6. CUSTOMER_JWT_PRIVATE_KEY=••••••••••••
  7. CUSTOMER_JWT_PUBLIC_KEY=••••••••••••
  8. # openssl rand -hex 32 - unsubscribe, open and click links
  9. EMAIL_LINK_SECRET=••••••••••••
  10. REVIEW_VOTER_TOKEN_SECRET=••••••••••••
  11. SIGNALS_SERVICE_SECRET=••••••••••••
  12. # openssl rand -base64 32, keep stable
  13. CONFIG_ENCRYPTION_KEY=••••••••••••
  14. SIGNALS_ENCRYPTION_KEY=••••••••••••
  15. DB_PASSWORD=••••••••••••
  16. SIGNALS_DB_PASSWORD=••••••••••••
  17. VALKEY_PASSWORD=••••••••••••
  18. CLICKHOUSE_PASSWORD=••••••••••••
  19. # The hosts browsers upload files to - the S3 presign host, or STORAGE_UPLOAD_URL
  20. UPLOAD_ORIGINS=https://media-bucket.s3.eu-central-1.amazonaws.com
env/each copied from its .example
FileRead by
.envdocker compose itself - the secrets, and the URLs built from them
env/prod.envapp, worker, media, signals, ingest and migrate - one shared file
env/prod-signals.envsignals alone, on top of prod.env - optional
env/prod-vetrina-demo.envthe storefront
env/prod-caddy.envthe opt-in Caddy edge - its domains and the ACME email
env/small.envthe small shape's one app container
env/small-vetrina-demo.envits storefront
app · stdouta refused boot · source left off
{"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"}

Edge

Step 03 of 08

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_PROXIES names what's in front, or every per-IP limit shares one bucket
hostnames → upstreamsthe same in both shapes
  • 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 - app proxies them on otherwise
  • api.example.commedia:8083Image transforms, likewise - app serves the route either way
--profile edgetakes :80 and :443
$ cp env/prod-caddy.env.example env/prod-caddy.env# domains + ACME email$ docker compose -f docker-compose.small.yml --profile edge up -d
deploy/caddy/Caddyfileyours to edit - the shop and the API
{$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}

Images

Step 04 of 08

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/amd64 unless 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_ORIGINS is 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

./skelet releasebin/push-images · docker output left out
$ 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 -d

Grow

Step 05 of 08

Grow 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=true with no worker running stops background work entirely
  • The media and ingest tiers serve the same paths app does, so routing them is a scaling choice
from one process to the full shapedashed: not in the compose files
  1. startnothing set

    One app container runs every backend role - the API, websockets, media transforms, the scheduler, webhook delivery and the outbox relay - beside one Postgres.

    • app
    • db
  2. envVALKEY_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
  3. envOPENSEARCH_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
  4. envJOBS_EXTERNAL=true

    The 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
  5. edge/media/t/* → media:8083

    Image 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
  6. envSIGNALS_ENABLED=true

    The 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
  7. edge/t/* → ingest:8091

    A 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

Managed

Step 06 of 08

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 bundled still on, every bundled container starts and refuses an empty password
  • sslmode=disable refuses to boot - managed providers want require or verify-full anyway
  • The spine's migrate one-shot runs against whatever SIGNALS_CH_URL names
  • Frontends on a platform like Vercel make CORS_ALLOWED_ORIGINS required, and TRUSTED_PROXIES has to name what's in front
.envthe whole change
  1. COMPOSE_PROFILES=bundled
  2. DATABASE_URL=postgres://…@your-managed-pg:5432/skelet?sslmode=require
  3. SIGNALS_DB_URL=postgres://…@your-managed-pg:5432/skelet_signals?sslmode=require
  4. VALKEY_URL=rediss://…
  5. SIGNALS_CH_URL=clickhouse://user:password@your-managed-ch:9440/signals?secure=true
docker compose -f docker-compose.prod.yml up -dwith the profile off

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

Operate

Step 07 of 08

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
healthchecks and drainsdocker-compose.prod.yml
  • 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
migrate · commandruns once, gates app
$ 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 0

Backups

Step 08 of 08

Back 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 to back updocs/backups.md
WhatHoldsHow
Commerce PostgresOrders, customers, catalog - everything that mattersPoint-in-time recovery
Signals PostgresCampaigns, flows, segments, the send logPoint-in-time recovery
ClickHouse spineRaw analytics events, Lens sessionsBACKUP DATABASE signals TO S3(...)
Object storageMedia, and Lens replay blobsBucket replication or versioning, or a snapshot of STORAGE_DIR
OpenSearchRebuilt from Postgres./skelet cli search reindex
ValkeyCaches and sessionsNothing to keep

Guides

In the docs