Skip to content

Config Custom Services

github-actions[bot] edited this page Oct 14, 2026 · 9 revisions

Config, Custom Services

Custom services are user-defined Docker containers that ɳSelf manages alongside the core stack. They live in numbered slots, CS_1 through CS_10, and are treated as first-class citizens: ɳSelf generates their docker-compose config, injects core service credentials, routes them through Nginx, and exposes them in nself status and nself logs.

Use custom services to run any application your project needs, a REST API, a background worker, a webhook processor, an AI inference service, without leaving the ɳSelf config system.


Overview

nSelf stack
  ├── postgres, hasura, auth, nginx  (core services — always present)
  ├── redis, minio, search, ...      (optional services — toggled by *_ENABLED)
  └── CS_1 .. CS_10                  (custom services — defined in .env)
        ├── any Docker image or nself template
        ├── auto-receives DATABASE_URL, HASURA_GRAPHQL_ADMIN_SECRET
        ├── optionally exposed via Nginx at {CS_N_ROUTE}.{BASE_DOMAIN}
        └── managed by nself start / stop / logs / status

Up to 10 custom service slots are available per project. Slots are independent, you can use CS_1 and CS_3 without defining CS_2.


Defining a Custom Service

Option 1, Shorthand (CS_N)

The CS_N variable accepts a single definition string that encodes the most common settings:

CS_N=name:template[:port][:route]
Field Required Description
name Yes Service name. Used in container labels, logs, and Nginx config.
template Yes Language/framework template. See Language Templates.
port No Port the service listens on. Auto-assigned if omitted.
route No Nginx subdomain. If omitted, the service is internal-only.

Example, a Go service on port 8001 exposed at ping.{BASE_DOMAIN}:

# .env
CS_1=ping_api:go:8001:ping

ɳSelf parses this into the service configuration. The Compose service name is sanitized: ping_api becomes ping-api. Use that name for logs and internal DNS. The default build context prefers the original directory services/ping_api/ when it exists.

Per-Service Overrides

Keep the CS_N definition and add supported overrides. CS_N_PORT and CS_N_ROUTE override the shorthand fields. An empty or absent CS_N skips the slot entirely, even if overrides are present. There are no CS_N_ENABLED, CS_N_NAME, CS_N_TEMPLATE, CS_N_WORKDIR, or CS_N_REPLICAS overrides. Set the working directory in the image's Dockerfile.

# .env
CS_1=ping_api:go:8001:ping   # base definition
CS_1_MEMORY=512m             # override the default 256m
CS_1_CPU=1.0                 # override the default 0.5
CS_1_PUBLIC=true             # expose via Nginx

Environment Variable Reference

N is the slot number (1–10). The name and template come from the required CS_N definition; the supported overrides are listed below.

Variable Type Default Description
CS_N string (empty) Shorthand definition. Format: name:template[:port][:route]
CS_N_PORT int parsed from CS_N or auto Port the service listens on inside the Docker network
CS_N_ROUTE string parsed from CS_N Nginx subdomain , set this to expose the service externally
CS_N_PUBLIC bool true when shorthand includes a route, otherwise false With a nonempty route, enables Nginx routing at {CS_N_ROUTE}.{BASE_DOMAIN}. Set explicitly when adding a route only through CS_N_ROUTE.
CS_N_MEMORY string 256m Docker memory limit
CS_N_CPU string 0.5 Docker CPU limit (fractional cores)
CS_N_HEALTHCHECK string /health Healthcheck override. A path (e.g. /auth/health) probes that path instead of /health on the service's own port. A full CMD ... / CMD-SHELL ... command is passed through verbatim (split on whitespace) for services that need curl, a non-HTTP probe, or a different port. disabled / none / false omits the healthcheck entirely.
CS_N_TABLE_PREFIX string (empty) Injected into the container as TABLE_PREFIX; the application decides how to use it
CS_N_ENV_PASSTHROUGH string (empty) Comma-separated allowlist of project .env var names to forward into this container in addition to the fixed core set. CS_N_ENV still wins on a name conflict.
CS_N_ENV_FILE string (empty) Project-relative path to a dotenv-format file whose KEY=VALUE lines are injected into this container, or a comma-separated list of such files (.env.dev,.env.secrets). Files load in order and a later file wins on a repeated key. Applied after CS_N_ENV_PASSTHROUGH, before CS_N_ENV. Use this instead of CS_N_ENV when a value itself contains a comma (e.g. some SMTP passwords) or when there are too many vars for one line. A missing file fails nself build rather than silently starting the service without those vars. Each entry must be relative and must not contain ...
CS_N_ENV string (empty) Additional env vars to inject, in KEY=VALUE,KEY=VALUE format. Always applied last — overrides the fixed core set, CS_N_ENV_PASSTHROUGH, and CS_N_ENV_FILE.
CS_N_IMAGE string (empty) Run a pre-built image instead of building from a Dockerfile — e.g. docker.io/pgsty/minio:RELEASE.2026-08-04T00-00-00Z@sha256:... to pin an exact digest. Mutually exclusive with CS_N_PATH; when set, no build: block is emitted at all.
CS_N_VOLUMES string (empty) Comma-separated extra bind mounts in host:container[:mode] form, e.g. ./email-templates:/app/templates:ro. Appended to the service's generated volume list. See Host binds.
CS_N_PATH string ./services/<name> Build context. A path inside the project, or an ancestor of the project written as .. segments only (e.g. ../.. for a monorepo root). See Ancestor build context. Mutually exclusive with CS_N_IMAGE.
CS_N_DOCKERFILE string Dockerfile Dockerfile path relative to the build context (for example backend/services/api/Dockerfile when the context is the monorepo root). No .., not absolute. Cannot be combined with CS_N_IMAGE.
CS_N_BUILD_TARGET string (empty) Multi-stage build target, emitted as build.target. Must match ^[A-Za-z0-9_.-]{1,64}$. Cannot be combined with CS_N_IMAGE.
CS_N_COMMAND string image default Container command in exec form. The value is split on whitespace: no shell, no quoting, no variable expansion. node dist/server.js becomes ["node", "dist/server.js"].
CS_N_DEPENDS_ON string (empty) Comma-separated name[:started|healthy|completed]. Adds compose depends_on entries next to the built-in postgres: service_healthy. healthy (the default) is service_healthy, started is service_started, completed is service_completed_successfully. A name must be a core service, another custom service or a service of an installed plugin; otherwise nself build fails with E500.
CS_N_NETWORKS string (empty) Comma-separated extra networks, each named <PROJECT_NAME>_<name> (lowercase letters, digits, -, _). They are emitted as external networks and attached next to the project network, so they must already exist (docker network create). Any other name fails with E501: a custom service never joins another project's network.

All CS_* variables are automatically exempt from "unknown env var" warnings.

Ancestor build context

CS_N_PATH may name an ancestor of the project directory so a service can build from a monorepo root:

# .env, project at <repo>/apps/api, repository root two levels up
CS_1=worker:node:9500
CS_1_PATH=../..
CS_1_DOCKERFILE=apps/api/services/worker/Dockerfile
CS_1_BUILD_TARGET=runtime

The whole context directory is sent to the image builder, so it is bounded:

  • It may reach at most the nearest ancestor of the project directory that holds .git. When no ancestor holds .git, the project directory is the limit.
  • The ignore file BuildKit will use (a <Dockerfile>.dockerignore beside the Dockerfile if one exists, otherwise .dockerignore in the context) must exclude .env* and .secrets at every depth: use the lines **/.env* and **/.secrets. A bare .env* is not enough, because Docker matches it at the context root only and apps/api/.env would still be sent. nself build checks this by matching probe paths (root, each directory down to the project, and below it) with Docker's pattern rules.
  • A ! line that can re-include a .env* or .secrets path (for example !**/.env.production or !**/.secrets/pub) is refused too. Only a name ending .example, .sample, .template or .dist may be re-included. A leading UTF-8 byte order mark is ignored, as Docker does.
  • Anything else fails nself build with E528, naming the directory.
  • A path that climbs and then descends (../sibling) is not an ancestor and is rejected at config load, naming CS_N_PATH.

Where the checks run

The ancestor-context bound, the ignore-file check and the Dockerfile symlink check run in nself build (compose generation). Config load checks syntax only, so a tool that loads the config without building does not enforce them. A CS_N_PATH inside the project that is itself a symlink leaving the project is not checked.

Dependencies and networks

CS_N_DEPENDS_ON is syntax-checked when the compose file is generated and resolved during build post-validation, after the plugin step has written .nself/compose-files.txt. A first nself build therefore already sees every plugin service. An unknown name fails with E500 naming CS_N_DEPENDS_ON and the name. An unknown condition fails with E500 naming the condition. A dependency cycle fails with E500 naming the path (api -> db2 -> api): custom-service cycles when the compose file is generated, cycles through core or plugin services in build post-validation.

The network prefix is a naming rule, not ownership. Another project whose name extends yours (app and app_staging) also owns names that start with app_, so choose network names no other project uses.

Extra networks are external: nself build declares them but does not create them. For PROJECT_NAME=myapp and CS_1_NETWORKS=myapp_shared, create the network before starting the stack:

docker network create myapp_shared

Host binds and the v1.5 rule

CS_N_VOLUMES keeps its v1.4 rules. In v1.5 mode (NSELF_V15=1, see Compat-V15) these entries fail with E502:

  • a writable absolute host path outside the project directory (add :ro, use a named volume or a project-relative path);
  • /var/run/docker.sock or / in any mode suffix.

In v1.4 mode the Docker socket and host root binds still work and print one warning. Named volumes, project-relative binds and read-only outside binds pass in both modes.

Not covered

These stay outside CS_N; see Custom-Services-Migration for the pattern table and the open gaps. There is no key that includes hand-written compose.


Env Var Injection

When a custom service container starts, ɳSelf automatically injects the following variables:

Variable Value Description
DATABASE_URL postgresql://user:pass@postgres:5432/db Full PostgreSQL connection string
HASURA_GRAPHQL_ADMIN_SECRET from project .env For making privileged Hasura API calls
PROJECT_NAME from project .env The project namespace
BASE_DOMAIN from project .env The root domain
ENV from project .env dev, staging, or prod
POSTGRES_HOST, POSTGRES_PORT postgres, 5432 Internal database address
POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD resolved Postgres config Database credentials
HASURA_GRAPHQL_ENDPOINT http://hasura:8080/v1/graphql Internal GraphQL endpoint
AUTH_SERVER_URL, AUTH_CLIENT_URL resolved Auth config Internal Auth server and client URLs
SERVICE_NAME, SERVICE_PORT, SERVICE_ROUTE parsed service configuration This service's identity
TABLE_PREFIX CS_N_TABLE_PREFIX Application table-prefix hint
REDIS_URL resolved Redis config Included when Redis is enabled
S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET active object-store config Included when storage is enabled; v1.5 ops profile omits these
CS_N_ENV values parsed from CS_N_ENV Any additional vars you define for this slot

The generated environment consists of that core set plus explicit overrides. Each service's surface area is explicit and auditable rather than silently coupled to whatever another part of the stack happens to define. To pull in a specific project .env var, name it in CS_N_ENV_PASSTHROUGH; to define a new one outright, use CS_N_ENV.

Example, reading injected vars in a Go service:

dbURL := os.Getenv("DATABASE_URL")            // injected by nSelf
adminSecret := os.Getenv("HASURA_GRAPHQL_ADMIN_SECRET") // injected by nSelf
apiKey := os.Getenv("MY_API_KEY")             // forwarded via CS_N_ENV_PASSTHROUGH

Forwarding Project Vars

Use CS_N_ENV_PASSTHROUGH to forward specific vars that already exist in the project's .env (a comma-separated allowlist of names, values are not repeated):

# .env
MY_API_KEY=sk_live_...
CS_2_ENV_PASSTHROUGH=MY_API_KEY,STRIPE_WEBHOOK_SECRET

Adding Extra Vars

Use CS_N_ENV to pass service-specific variables that aren't in the main .env, or to override a value that was forwarded via CS_N_ENV_PASSTHROUGH (CS_N_ENV always wins on a name conflict):

# .env
CS_2_ENV=QUEUE_SIZE=100,WORKER_TIMEOUT=30,LOG_FORMAT=json

Language Templates

nself service add <name> --template <lang> writes starter source files and a Dockerfile, then registers the next free slot in .env.dev.

nself service add my-api --template go # doc-check: skip registered in cmd/commands/service.go; no command-signature fixture yet

Available templates:

Template Description
go (default) Go HTTP service
node Node.js service
python Python service
static Static file server
rust Rust service
other Minimal scaffold for anything else

The Go, Node, Python, and Rust scaffolds expose /healthz; the default generated healthcheck probes /health with wget. Set CS_N_HEALTHCHECK=/healthz for those apps if the final image supplies wget, or supply a suitable CMD/CMD-SHELL probe. Static and other scaffolds need a probe appropriate to their application. Adapt the starter image and probe before relying on health-based dependencies.


Real-World Example, ping_api (web/backend CS_1)

The ɳSelf infrastructure itself (web/backend) uses CS_1 to run ping_api, a Go service on port 8001 that handles telemetry and license validation for the CLI. It is accessible at ping.nself.org.

# web/backend/.env
CS_1=ping_api:go:8001:ping
CS_1_PUBLIC=true
CS_1_MEMORY=128m
CS_1_CPU=0.25
CS_1_HEALTHCHECK=/health

This configuration produces:

Derived value Result
Compose service name ping-api
Template go
Internal port 8001
Route ping
External URL https://ping.nself.org
Internal URL http://ping-api:8001 (within Docker network)

The ping_api service receives DATABASE_URL and HASURA_GRAPHQL_ADMIN_SECRET automatically on startup, so it can validate license keys against the database without any additional configuration.


Quick Start

1. Create a service using a template:

nself service add my-api --template go # doc-check: skip registered in cmd/commands/service.go; no command-signature fixture yet

This scaffolds a Go service in ./services/my-api/ with a Dockerfile and /healthz endpoint, and assigns it the next available CS_N slot and port automatically.

2. Configure the assigned slot in .env.dev:

The command already registers the service. If it assigned CS_1, add a route and a healthcheck suitable for your final image; keep the registered name:

# .env.dev
CS_1=my-api:go:8001:api
CS_1_MEMORY=256m
# Supply a probe tool in the image, or use an application-specific probe.
CS_1_HEALTHCHECK=/healthz

3. Rebuild and start:

nself build     # regenerates docker-compose.yml with CS_1 included
nself start     # brings up the full stack including my-api

4. Verify it's running:

nself status    # shows all services including custom ones
nself logs -f -S my-api  # follow logs by Compose service name

Once the application is running and routing is configured, its route is https://api.{BASE_DOMAIN} and its internal address is http://my-api:8001.


Multiple Custom Services

You can define up to 10 slots simultaneously. Slots are independent, gaps are fine.

# .env
CS_1=ping_api:go:8001:ping           # telemetry service
CS_2=worker:node:8002                 # background job worker (internal-only, no route)
CS_3=cms_api:python:8003:cms          # FastAPI CMS
CS_5=search_proxy:go:8005:search      # search proxy (skipped CS_4 intentionally)

# Override resources on the worker
CS_2_MEMORY=512m
CS_2_CPU=1.0

Notes

  • Custom service images are built from the scaffolded Dockerfile in ./services/{name}/. To use a pre-built image instead, set CS_N_IMAGE directly — a full image reference, optionally digest-pinned with @sha256:....
  • CS_N_TABLE_PREFIX supplies the container's TABLE_PREFIX environment variable. It does not configure CLI migration or backup filtering.
  • If a service's health endpoint isn't /health on its own port (e.g. an auth service serving /auth/health), set CS_N_HEALTHCHECK=/auth/health — otherwise Docker probes the wrong path and reports the service unhealthy forever regardless of its actual state.
  • nself logs includes all Compose services by default. --all increases the tail to 100 lines; select a custom service by its Compose name, not its CS_N slot.
  • CS_N_IMAGE, CS_N_ENV_FILE, and CS_N_VOLUMES cover the cases that previously forced a hand-authored docker-compose.override.yml: a pinned third-party image digest, many/complex injected env vars (e.g. SMTP credentials), and an extra bind mount (e.g. an email-template directory) — see the reference table above.

For a step-by-step walkthrough of building and deploying a custom service from scratch, see Guide-Custom-Services.

← Config-Optional-Services | Configuration | Guide-Custom-Services →

Home


Getting Started


Commands


Features


Configuration


Plugins (87 + 10 monitoring)

Free (25)
Pro (62)
Planned (26)
  • plugin-audit
  • plugin-blog
  • plugin-checkout
  • plugin-commerce
  • plugin-drm
  • plugin-export
  • plugin-flow
  • plugin-import
  • plugin-ldap
  • plugin-mailgun
  • plugin-media
  • plugin-oauth-providers
  • plugin-pages
  • plugin-postmark
  • plugin-rate-limit
  • plugin-reports
  • plugin-saml
  • plugin-scheduler
  • plugin-sendgrid
  • plugin-sso
  • plugin-subscription
  • plugin-thumb
  • plugin-transcoder
  • plugin-twilio
  • plugin-waf
  • plugin-watermark

Guides


Architecture


Reference


Machine interface


Licensing


Security


Brand


Operations


Contributing


Admin


Changelog


All commands (21)

Clone this wiki locally