2serverDocumentationGitHub
2server / DocumentationRead Markdown (.md) View source

Source configuration

Use one reviewed YAML or JSON document per application or shared extension. The schema version is 2server.app/v1. Unknown fields and unsupported versions fail validation. One App file contains its complete desired configuration. Environment variables in YAML are literal; there is no shell interpolation or automatic local .env substitution.

text
api/2server/deploy.yaml
web/2server/deploy.yaml
platform/redis.yaml
platform/postgres.yaml
platform/nats.yaml
platform/monitoring.yaml
.2server/connection.yaml    # gitignored; private per operator
bash
2srv validate -f api/2server/deploy.yaml
2srv plan -f api/2server/deploy.yaml
2srv deploy -f api/2server/deploy.yaml --apply
2srv deploy -f api/2server/deploy.yaml --image registry/api:latest --apply
2srv get -f api/2server/deploy.yaml
2srv rollback -f api/2server/deploy.yaml --apply
2srv delete -f platform/redis.yaml --apply

apply and deploy reconcile one file. Without --apply, they validate and show a plan. App and Domain plans inspect Cloudflare DNS ownership, conflicts, TLS and cache policy before rollout; they do not issue certificates or test write permissions. Plans contact the VM and may pull registry image layers to resolve a tag; they do not change workloads. --image is optional and overrides only that execution. The source file is never rewritten by deployment.

--connection FILE is optional. Discovery searches upwards from the current working directory for the nearest .2server/connection.yaml; legacy connection.json is supported. Connection files use the existing SSH object (kind: ssh or kind: gcp). A relative SSH identity path resolves relative to its connection file. connect --ssh user@host writes a private, ignored YAML connection. Different users can connect to the same fixed root-owned VM state.

Generate an editable App with its domain using 2srv init app NAME -o app/2server/deploy.yaml. New VMs use the bootstrap quick start.

App document

yaml
apiVersion: 2server.app/v1
kind: App
metadata:
  name: api
spec:
  image: registry.example.com/api:latest
  port: 8080
  healthPath: /readyz
  memoryMb: 512
  cpus: 1
  replicas: 1
  stopTimeoutSeconds: 60
  drainSeconds: 70
  env:
    NODE_ENV: production
  secrets:
    DATABASE_URL:
      provider: vm
      key: DATABASE_URL

The spec uses src/modules/apps/domain/schema.ts:appSchema, with a tag or digest allowed for image. Defaults: service kind, one replica, /healthz, 60-second stop timeout, 70-second drain, empty env/secrets. Port, memory, CPU and image are required. Workers require a healthCheck or Docker image HEALTHCHECK and use the existing worker strategy. Services use CLI-managed blue-green; database extensions do not.

A file replaces the app's desired spec, rather than merging the VM's old spec. Removing a public env or secret reference removes that runtime override on the next deployment. Image-defined ENV defaults still apply. Secret values remain on the VM independently, so a different source checkout can reference them. Missing required fields or missing declared secret values fail deployment.

Optional top-level requires contains {kind: App|Extension, name: ...} entries; missing dependencies fail rather than being silently installed. Optional domains contains complete domain specs and is reconciled after the app is healthy. Separate kind: Domain files are also supported. Domains are independent resources: removing an entry does not delete DNS; explicitly delete its Domain file to retire it. Domain failure after a healthy app rollout is reported as a partial operation; the successful app state is retained.

One manifest, generated runtime

This follows the Kubernetes Deployment boundary: source declares the desired workload; the controller owns concrete instances and their current/previous state. It is a 2server schema, not a Kubernetes API or scheduler. Do not author Compose or Caddy files for an App.

The CLI generates restart policy, init, log rotation, resource limits, network attachment and container isolation. healthPath is the HTTP readiness path used before switching traffic and by Caddy. progressDeadlineSeconds (default 240, maximum 3600) bounds candidate readiness. Adopted Compose apps require 20 seconds of sustained readiness and another 20-second observation before commit.

Optional healthCheck declares a Docker exec health check:

yaml
spec:
  healthCheck:
    command: [wget, --spider, -q, http://localhost:8080/readyz]
    intervalSeconds: 30
    timeoutSeconds: 5
    startPeriodSeconds: 60
    failureThreshold: 3
  volumeMounts:
    - name: data
      mountPath: /data
  instanceEnv:
    INGEST_DURABLE: 'app-consumer-${generation}'

Keep readiness and liveness semantics separate: Docker health status alone does not restart an unhealthy container. labels: {autoheal: "true"} opts into an already installed autoheal service; 2server does not install one implicitly. Workers can supply healthCheck or use an image HEALTHCHECK. command overrides image CMD, not ENTRYPOINT. Omitting command/healthCheck removes the source override; image defaults apply.

volumeMounts declares a logical per-instance persistent volume, with optional readOnly. There are no host binds. Instances use separate volumes; these are not replicated storage. New volumes have server/app ownership labels. During adoption, mounts bind by mount path to the VM's existing physical volumes; their names are saved only on the VM. Missing adopted volumes fail instead of silently creating empty data. Removing a mount never deletes its volume. Concurrent writable generations cannot share the same physical volume.

instanceEnv is explicit public instance configuration; only ${generation} is substituted (blue or green). Ordinary env remains literal. These keys cannot overlap env or secrets. Replica instances of the same generation share this value, so use it for a consumer group, not a per-replica unique identifier. Do not rename a stateful consumer or volume claim without reviewing its data migration. Arbitrary labels are supported; CLI/Compose ownership labels are reserved.

Existing adopted apps retain VM-owned project/container names and Caddy paths. The new single-file form reads these identities from the latest VM snapshot and replaces only the candidate service. All desired settings come from the current source file; old public env, command, probes and mounts are not merged back. The active generation stays unchanged for rollback. Different source versions can use the same VM secrets and bindings with no local state synchronization.

Legacy spec.compose plus runtimeFile input remains readable for older checkouts. Paths resolve relative to that manifest. New manifests should omit both; do not export VM runtime templates (they can contain secret values). Changing ownership bindings still requires explicit adoption/migration.

Secrets

App files must use provider: vm references. The namespace is the app name, including named template Apps. Template secrets are declared in the top-level secrets map, matching its passwordEnv/tokenEnv etc. Server/provider credentials and legacy singleton Extension files use the server namespace. Neither list nor configuration export prints values.

bash
# Private dotenv file; never commit it. Values are parsed, never sourced.
2srv secret set --app api --env-file /private/api.env --apply
2srv secret list --app api
2srv secret delete --app api --key UNUSED_SECRET --apply

# Server/provider and legacy singleton extension credentials
2srv secret set --env-file /private/platform.env --apply
2srv secret list

set upserts supplied keys and retains others. delete refuses a key referenced by the deployed configuration. Changing a value does not restart an app; deploy its file to use the new value. Values cannot be passed as CLI arguments. Secrets are stored root-only on the VM and included in encrypted server backup output. The existing server env command remains supported; --app NAME selects the app namespace. Local environment values never substitute for missing VM secrets.

Apps from templates

Cloudflare email forwarding uses the email-routing template with an account, zone and forwarding routes. It is managed directly in Cloudflare and creates no VM installation; an optional --connection reads only its VM-owned API token. See email configuration for its source file and Free-plan limits.

bash
2srv init app cache --template redis -o platform/cache.yaml
2srv secret set --app cache --env-file /private/cache.env --apply
2srv deploy -f platform/cache.yaml --apply
2srv app cache logs
2srv app cache help

The source contract is kind: App, a user-chosen metadata.name, and template:

yaml
apiVersion: 2server.app/v1
kind: App
metadata: {name: orders-db}
template: postgres
spec:
  passwordEnv: POSTGRES_PASSWORD
  adminPasswordEnv: POSTGRES_ADMIN_PASSWORD
  migrationPasswordEnv: POSTGRES_MIGRATION_PASSWORD
secrets:
  POSTGRES_PASSWORD: {provider: vm, key: POSTGRES_PASSWORD}
  POSTGRES_ADMIN_PASSWORD: {provider: vm, key: POSTGRES_ADMIN_PASSWORD}
  POSTGRES_MIGRATION_PASSWORD: {provider: vm, key: POSTGRES_MIGRATION_PASSWORD}

The template supplies its validated defaults and lifecycle. spec is the selected template's schema. Values use secret set --app orders-db; the same key names in another app are independent. *Env fields map to the top-level secret references; references may use a different VM key. No local/global secret fallback exists for a named template app. Optional domains works as for image Apps, after a healthy rollout. Monitoring also contributes its own protected domain.

The VM records a template installation only after successful deploy. Generic app NAME get|deploy|logs|restart|delete works for both image and template apps. app NAME help adds only that installed template's commands. PostgreSQL supplies backup/recovery; monitoring supplies webhooks [test RECEIVER [--apply]]. Edit receiver definitions/references in the monitoring App file, then deploy it.

Data paths default to /opt/2server/data/NAME, and release state, container names, secrets, timers and recovery targets use the instance name. Multiple apps can use one template. Data path/disk/database/user changes and template switching are refused for an installed instance; migrate explicitly. Deletion retains data. Stateful apps do not use blue/green or traffic rollback; use their restore commands.

Legacy kind: Extension and kind: Service remain readable. Existing singleton extensions keep their runtime names and global secret references; the new app workflow does not silently adopt or move them. A one-off Service remains available through advanced compatibility commands; prefer App templates for reuse.

Images, concurrency and history

Every tag (including latest) is pulled from the registry on the VM for every plan/apply, then pinned to the returned digest. Registry errors stop deployment; there is no cached-tag fallback. Unchanged content-addressed layers can be reused. Once resolved, that exact digest is used throughout the transaction. Extension images preserve their tag alongside the digest for major-version checks.

Build scripts should pass the digest of their own build, not a shared latest tag. Build/push stays in those scripts; declare VM-side migration tasks in spec.preDeploy. For adopted apps marked migrationRequired, configure preDeploy; legacy apps without it require --migrations-applied after migration succeeds. This is an assertion, not a command that runs migrations. Rollback restores a saved generation; it does not undo database migrations.

Each CLI session fetches the VM revision. Independent image-app applies reserve only their app and physical bindings, then merge their changes into the latest snapshot at commit. A source app's domain phase takes its own reservation after the healthy rollout is saved. Shared infrastructure operations remain exclusive. Reads and all plans, including tag resolution, skip operation locks and create no control revisions or history. Per-app and short edge kernel locks protect runtime changes. See locking boundaries. Separate plan and apply commands recompute against current state; a prior plan is not an approval token.

Up to 20 recent successful file mutations retain their source document and resolved app config/digest in private VM control state and encrypted backups. The CLI does not require matching Git checkouts or sync local state. An older checkout can intentionally deploy older config; it cannot silently overwrite a concurrent transaction. Only one generation serves a given app at a time.

Pre-deploy tasks

Declare a migration program shipped inside the app image:

yaml
spec:
  preDeploy:
    command: [bun, run, scripts/migrate-schema.ts]
    timeoutSeconds: 300

The CLI runs this once for every applied deployment (including the same image), using the resolved candidate image and candidate environment/secrets on the VM, under the app lock before starting the candidate or stopping current workers. This is an argv array, not shell text; use an explicit shell only when needed. The one-shot container uses the edge network and app resource limits, with no app data volumes, host mounts, published ports or Docker socket. Use it for external database migrations; per-instance database initialization belongs to that instance. A nonzero exit/timeout aborts rollout and removes the task container. Logs stay private beside the VM release's app.env as pre-deploy.log. Migrations must be idempotent and compatible with the still-serving old app. Traffic rollback does not undo database changes and does not rerun preDeploy.

Optional preDeploy.secrets uses the same secret-reference map as spec.secrets. It overrides candidate environment keys only for the one-shot task. For example, set preDeploy.secrets.DATABASE_URL: {provider: vm, key: MIGRATION_DATABASE_URL} while spec.secrets.DATABASE_URL references the app's DML-only credential. The CLI writes task overrides to a separate root-only pre-deploy.env; those values are not injected into the running application or its Compose environment. Source plans reject missing task secrets before rollout. Secret deletion refuses keys still referenced by either the app or its pre-deploy task. Scaling to zero skips the task; other applied releases rerun it.

Build scripts should build/push and call 2srv deploy -f FILE --apply. With preDeploy configured, remove local migration/secret-fetch commands and --migrations-applied. That flag remains a legacy acknowledgement for apps without a configured hook; it never bypasses a configured preDeploy task.

Restricted capabilities

App containers drop all Linux capabilities by default. capabilities: [NET_BIND_SERVICE] explicitly restores only low-port binding; other additions (such as SYS_ADMIN) are rejected. Caddy images with a file capability on the binary need this even when configured to listen on port 8080: otherwise Linux can reject exec with Operation not permitted. This applies to native/Compose containers and preDeploy tasks, with no-new-privileges retained.

Cloudflare zone policy

Rate-limit and Cache Rules capacity are shared by a Cloudflare zone. Declare one kind: Zone source file for it, separate from App and hostname-specific Domain files:

yaml
apiVersion: 2server.app/v1
kind: Zone
metadata:
  name: example-com
spec:
  zone: example.com
  rateLimit:
    scope: zone
    rules:
      - name: image-submit
        paths: [{value: /photo-api/jobs, match: exact}]
        requests: 5
        period: 10
        mitigationSeconds: 10
        excludeVerifiedBots: true
        enabled: true
sh
2srv validate -f platform/cloudflare-zone.yaml
2srv plan -f platform/cloudflare-zone.yaml
2srv apply -f platform/cloudflare-zone.yaml --apply
2srv get -f platform/cloudflare-zone.yaml

apply / deploy operate on the resource kind in the file. A Zone apply only calls Cloudflare and records its policy in VM config under cloudflare.zones[zone]; it never pulls an image or restarts an app. No separate protect command is needed. An App deployment does not modify zone policy. Plans are read-only. Zone applies hold the server operation lock to serialize shared provider policy updates. Use one source of truth for each zone; rule ownership uses server + zone + rule name, independent of application names.

Use Cloudflare token setup to select the correct Zone permissions and resource scope for the declared policies. The connected server's cloudflare.tokenEnv needs Zone Read and Zone WAF Edit for the managed zone. Import credentials privately with the normal server secret workflow. The CLI never prints tokens/provider error bodies, buys an upgrade or replaces a foreign rule to make space. Existing rules, including disabled rules, consume the shared capacity. Free supports one rule with a 10-second counting window and a 10-second block. Free cannot match hostname; scope: zone explicitly matches the declared paths across all proxied hosts. On Pro or higher use scope: hosts and hosts: [api.example.com] to narrow the scope.

Custom WAF rules

spec.wafRules manages rules in the http_request_firewall_custom phase (the "Custom rules" list in the dashboard). Rules are host-scoped by design — there is no zone-wide form:

yaml
spec:
  zone: example.com
  wafRules:
    - name: seo-tool-denylist
      hosts: [example.com, www.example.com]
      action: block                  # or managed_challenge
      userAgents: [ahrefsbot, semrushbot]   # lower(http.user_agent) contains …
      excludeVerifiedBots: true      # adds "and not cf.client.bot"
      exceptPaths: [/robots.txt, /ads.txt]  # exact-path exemptions
      enabled: true
  • userAgents are case-insensitive substrings of lower(http.user_agent). UA strings are attacker-controlled: keep verified-search-engine tokens (googlebot, bingbot) paired with excludeVerifiedBots: true so the rule catches impersonators, not the real crawler that needs to read robots.txt.
  • Custom-rule quota: Free 5, Pro 20, Business 100, Enterprise 1000; existing foreign rules count. Ownership is 2server:<server>:zone:<zone>:waf:<name>.
  • Token needs Zone Read + Zone WAF Edit (same permission as rate limits).

Change paths, requests, periods or enabled and apply the Zone file. The first version uses IP counting and block actions. Supported periods and rule ceilings are checked against the reported plan; Enterprise contract-specific entitlements remain subject to provider validation. Updates target one owned rule at a time and reread capacity before each write. Provider changes are not transactional; a partial failure can leave some rules applied, so inspect and rerun the plan.

Omitted rules are retained. Set enabled: false to disable an owned rule; explicit retirement in Cloudflare frees its slot. rules: [] inspects capacity without adding rules. Zone delete/rollback is refused rather than implicitly removing live protection. Renaming the zone points to a different resource and does not retire policies in the original zone.

Turnstile belongs to the application. 2server does not create widgets, configure challenges, generate keys or validate tokens. Each app owns its frontend/native integration, backend Siteverify checks and widget lifecycle. Apps may reference their already-provisioned keys through ordinary spec.secrets, just like any other application secret. There is no spec.protection on App. Per-user permissions and exact spending quotas also remain backend concerns.

Optional Cache Rules

spec.cacheRules is optional and independent of spec.rateLimit. A cache-only Zone needs no rate-limit block. Rules require explicit hosts inside spec.zone and at least one path matcher: {exact: /path} or {prefix: /path/, suffix: /image} (suffix optional). Hosts are ORed; paths are ORed; prefix and suffix on the same entry are ANDed. No raw expressions or regex.

yaml
apiVersion: 2server.app/v1
kind: Zone
metadata:
  name: example-com
spec:
  zone: example.com
  cacheRules:
    - name: public-template-images
      hosts: [photos.example.com]
      paths:
        - prefix: /local-api/templates/
          suffix: /image
        - exact: /assets/logo-v1.png
      mode: respect-origin
      cookies: allow
      bypassCookies: [__Host-admin]
      enabled: true
  • mode: respect-origin (default) makes matching GET/HEAD requests eligible for caching and respects origin edge/browser TTL headers. It does not force-cache private / no-store responses or supply a TTL. The app must send public cache headers, for example public, max-age=0, s-maxage=300.
  • mode: bypass disables cache for matching hosts/paths, for all methods and regardless of cookies or authorization.
  • cookies: bypass (default) excludes requests with any Cookie header from this eligibility rule. Use allow only for assets identical for every viewer. bypassCookies excludes requests containing any listed cookie name. These exclusions skip this rule; they do not undo another rule that enables caching. Retain Domain.cache: app as the bypass baseline, or declare an explicit mode: bypass rule last for sensitive paths.
  • Requests with an Authorization header are always excluded from eligibility. Full query strings remain in the default cache key, including ?v=2. Do not configure another rule to ignore them if the app uses versioned URLs.
  • enabled defaults to true. Names must be unique and stable. Omitting a rule retains it; enabled: false disables it without freeing a quota slot.

Use the same validate, plan, apply -f FILE --apply and get commands above. The token needs Zone Read and Cache Rules / Cache Settings Edit for cache rules; WAF Edit is only needed when also configuring rate limits. Preflight checks both requested policies before any provider write. Cache Rules have a separate quota: Free 10, Pro 25, Business 50, Enterprise 300 (custom contracts remain subject to provider validation). Existing manual and disabled rules count too.

Zone rules run after Domain cache presets, in source list order; later matching settings win. Redeploying a Domain keeps its presets before Zone overrides. Only named owned rules are patched/created, preserving foreign rules. Review the read-only plan when mixing policies or changing order. Rules omitted from this file remain earlier in the ruleset; use one Zone file as the source of truth. Domain.cache: app | images | audio remains supported for existing deployments. A Zone apply changes Cloudflare and VM policy metadata without deploying the app. For newly changed response headers, deploy the app separately. A cached image can remain public until its TTL expires even after origin visibility changes; use a bounded TTL or explicitly purge it in Cloudflare when immediate removal is required.

References: Cache Rules limits, Cache settings.

Reference: Cloudflare rate-limit availability.