DocsRunning a store
Deploying
On this page4 sections
Two shapes, same images - the difference is env, not a build. Both bind every service to loopback, so something has to sit in front and terminate TLS.
The two shapes
docker-compose.small.yml is the minimum deployable tenant: one app container
running every backend role (API, WS, media transforms, scheduler, webhook
delivery, outbox relay) and one Postgres. No Valkey, no NATS, no OpenSearch, and
no Signals - the suite, with the ClickHouse its spine lives in, is the prod
shape’s. Its ports are offset (18080/18081/13000/15432) so it runs beside the
dev stack.
cp env/small.env.example env/small.env
cp env/small-nextjs-vetrina.env.example env/small-nextjs-vetrina.env
cp .env.example .env # session and link keys, SIGNALS_SERVICE_SECRET, CONFIG_ENCRYPTION_KEY, DB_PASSWORD - skelet setup fills them
docker compose -f docker-compose.small.yml up -d
Each storefront runs under a compose profile of its own name, so the demo one
also needs COMPOSE_PROFILES=bundled,nextjs-vetrina in .env. skelet setup
with the small or prod target does all of this for the storefronts you pick: it
copies only their env files, turns their profiles on, and points
STOREFRONT_REVALIDATE_URL and the edge at the one it serves.
docker-compose.prod.yml is the full shape: the tiers split into their own
containers (worker, media, signals, ingest), pgbouncer per database, three split
Valkeys (primary, cache, rate limits), ClickHouse for the spine, and the optional
NATS profile. Same env contract - secrets in the root .env, everything else in env/prod.env, plus an optional
env/prod-signals.env for what only the Signals process reads (the retention
archive’s bucket, CORE_FRONT_API_URL). skelet release
builds and pushes the four images it reads: the backend (APP_IMAGE, also the
worker and media tiers), the Signals module (SIGNALS_IMAGE, the signals and
ingest tiers plus the ClickHouse schema), the dashboard and each storefront.
Scaling from one to the other is that env changing: set VALKEY_URL for
multi-replica, then JOBS_EXTERNAL to split the
background tiers back out.
The root .env is the only file docker compose interpolates, which is why the
secrets that have to appear twice (a datastore and the URL reaching it) live
there rather than in env/. Everything else is bulk config handed to one
container through env_file:. See env.md.
Managed datastores
The bundled Postgres, pgbouncer, Valkey and ClickHouse containers sit behind the
bundled compose profile, and .env.example ships COMPOSE_PROFILES=bundled - so a
self-hosted box gets them by default and nothing changes.
Moving to managed services is dropping that line and pointing the URLs outward:
# .env
# COMPOSE_PROFILES=bundled <- removed
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
The eight containers (two Postgres, two pgbouncers, three Valkeys and ClickHouse)
then never start, instead of idling beside the databases you are actually paying
for. The *_PASSWORD lines go unused too, so leave them empty. Everything else -
app, worker, media, signals and ingest - is the same image and the same file.
The signals process migrates the spine on whatever SIGNALS_CH_URL names as it
boots, so the schema lands on the managed server without a second URL - a TLS
endpoint is clickhouse://user:password@host:9440/signals?secure=true.
Two things that bite:
- It’s all or nothing: with
bundledstill on, every bundled container starts and refuses an empty password, including the ones whose URL you pointed elsewhere. Going half managed means keeping the passwords set. - Every
depends_onon those services carriesrequired: false. Without it compose rejects the entire file the moment the profile is off, because the app tiers declare a dependency on a service that no longer exists. envcheckrefuses to boot ifDATABASE_URLhassslmode=disable. Managed providers wantrequireorverify-fullanyway;ALLOW_INSECURE_DB=1exists for the case where you terminate TLS yourself.
If the frontends move to a platform like Vercel, CORS_ALLOWED_ORIGINS stops
being optional - the browser starts talking to the API origin directly instead of
being proxied to it by the dashboard’s own nginx - and TRUSTED_PROXIES has to
name whatever sits in front of the API, or forwarded client IPs are wrong.
Uploads go from the browser straight to object storage, so whichever host that
is has to be in UPLOAD_ORIGINS, or every CSP on the stack blocks the upload -
see env.md.
Putting an edge in front
Three hostnames, whoever terminates TLS. The upstream ports are the same in both stacks (the small profile publishes them on offset loopback ports so it can run beside the dev stack, but the in-network ports don’t move):
| Host | Upstream | Why it exists |
|---|---|---|
apex, e.g. shop.example.com | <storefront>:3000, its compose service | the storefront - every channel’s host can point at the same upstream |
admin. | dashboard:8080 | the admin SPA - its own nginx proxies /api/* to the backend in-network, so it needs no API host |
api. | app:8080 | inbound payment webhooks. Providers POST to /api/v1/payments/webhook/<provider>, which is the URL you register with your payment provider. Skip this host and captures never settle |
/t/* is the Signals tier’s, and app proxies it there per SIGNALS_INGEST_URL
- one public entry point, no routing needed. Route
/t/*atingest:8091to skip the hop once that tier is scaled out, and/media/t/*atmedia:8083likewise.
HSTS belongs here and nowhere else. The backend and the dashboard’s nginx each
ship their own copy of the CSP and Permissions-Policy so a deployment without an
edge still gets them, but they deliberately omit HSTS - asserting it over plain
HTTP would pin a browser to HTTPS for a year. skelet check contracts fails when
those copies drift apart.
The bundled option. deploy/caddy/Caddyfile has all of the above plus
automatic Let’s Encrypt certs and renewal. It’s behind a profile because it takes
:80 and :443, which is the wrong move if you already run nginx, Traefik, or a
load balancer:
cp env/prod-caddy.env.example env/prod-caddy.env # domains + ACME email
docker compose -f docker-compose.small.yml --profile edge up -d
The vhost map is meant to be edited, all but the apex’s upstream: that one reads
STOREFRONT_UPSTREAM from env/prod-caddy.env, which skelet setup points at
the storefront you run, and falls back to nextjs-vetrina:3000. A shop of your
own needs a service in the prod or small file first - the template’s block,
renamed along with its profile.
More than one channel. One storefront image and one container serve every
channel: add each channel’s host to STOREFRONT_CHANNEL_HOSTS in the
storefront’s env file and point the host at the same upstream. The edge has to
pass the original Host through, which Caddy and nginx’s
proxy_set_header Host $host both do. Each channel’s pages and cache entries
live under their own path inside the app, so a new channel is a new host, not a
new deployment. STOREFRONT_REVALIDATE_URL pointing at that service purges
every channel; see env.md.
Keep it to one replica. The rendered pages and the data cache live in the
storefront process, and nothing shares them between containers. With two
replicas behind a load balancer, a purge lands on whichever one answers it and
the other keeps serving the old page until its own timer runs out. So run one
storefront replica per deployment, and give it more CPU before you give it a
twin. More replicas only work once a shared cacheHandler (Redis or similar)
is configured in next.config.ts, which the storefront doesn’t ship.
Operations
Health. The API serves /health (pings Postgres + Valkey) and /readyz
(503s while optional pieces are unconfigured); the worker, media, signals and
ingest tiers each serve /healthz. The compose healthchecks already point at all
of these.
Backups. skelet takes none - that belongs to your infrastructure. Turn on
point-in-time recovery if you’re on managed Postgres, or run pgBackRest or wal-g
against the bundled containers, and back the ClickHouse spine up with its own
BACKUP DATABASE. What needs covering and what doesn’t:
backups.md.
Tracing and metrics. OpenTelemetry, opt-in - nothing is exported until you
set the OTEL_* vars. Sentry activates when a DSN is set. In dev, the optional
grafana tool gives you a local Grafana to send them to
(workflow.md); in production, point the vars at the
collector you already run.
Logs. Every service writes structured JSON to stdout, and the compose files cap Docker’s copy at 10 MB x 3 per service, so that cap is also the retention. Shipping and searching history is the deployment’s job, not the repo’s: point a collector (Vector, Fluent Bit, Grafana Alloy, the OpenTelemetry Collector) at the container logs and send them to whatever log store you already run - Grafana Loki and VictoriaLogs are the usual self-hosted answers, both with a UI. Nothing in the stack depends on it being there.
