Skip to content

Writing a Service

This guide walks through creating a minimal service package from scratch. We'll build a simple Redis service.

1. Create the package directory

services/redis/
  service.yaml
  scripts/
    install.js
    configure.js

2. Define service.yaml

yaml
name: redis
nodes:
  server:
    count: 1
    ports:
      - port: 6379
        from: private
    actions:
      install: scripts/install.js
      configure: scripts/configure.js

This declares a single-node service with one port open to your private networks (use from: public for a port that should serve the internet).

3. Write the install script

scripts/install.js runs once on first setup:

javascript
const managed = require("managed");

// Install Redis
managed.packages.install(["redis-server"], { sudo: true });

// Ensure it starts on boot
managed.systemd.enable("redis-server");

managed.log.info("Redis installed");

4. Write the configure script

scripts/configure.js runs after install (and on subsequent applies when config may have changed):

javascript
const managed = require("managed");

const maxmemory = managed.params.maxmemory || "256mb";

const config = `
bind 0.0.0.0
protected-mode no
maxmemory ${maxmemory}
maxmemory-policy allkeys-lru
`;

managed.files.write("/etc/redis/redis.conf", config, { sudo: true });
managed.systemd.restart("redis-server");

managed.log.info("Redis configured with maxmemory=" + maxmemory);

The maxmemory value comes from the user's managed.yaml:

yaml
services:
  cache:
    package: redis
    config:
      maxmemory: 1gb

Fields under config are available via managed.params.

5. Deploy

bash
managed apply

The platform creates a server, runs install.js, then configure.js, and sets up firewall rules for port 6379.

Config form widgets

The dashboard builds the "add a service" form straight from your params:. The widget is inferred from the param's type and name (a *_password field masks and offers a generate button, a boolean becomes a toggle, an enum becomes a dropdown, and so on), so most packages need no extra metadata. Optional fields, label, description, group, advanced, placeholder, unit, secret, generate, multiline, only shape how it looks; they never affect a deploy.

Pickers

A widget: override turns a free-text field into a selector that autocompletes against the project's live data, so users pick a real name instead of typing one:

yaml
params:
  # Pick one service already configured in managed.yaml.
  backends:
    type: string
    required: true
    widget: service-picker
    description: "Service to load balance"

  # Pick several, the value is then a list of service names.
  upstreams:
    type: string
    widget: service-picker
    multiple: true
    # Optional: only offer services of these packages.
    picker_filter: [app, worker]

  # Pick a server from the project's pool (single or multiple).
  pinned_servers:
    type: string
    widget: server-picker
    multiple: true
  • widget: service-picker, choose from the services in services:. Narrow the suggestions with picker_filter: [<package>, …] (omit for all services).
  • widget: server-picker, choose from the servers in the project's pool.
  • multiple: true, multi-select; the saved value becomes a YAML list. Read it in a script as an array (managed.params.upstreams.forEach(…)). Without it the value stays a single string. multiple also works on an enum param, turning it into a multi-select of the allowed values.

Both pickers still accept a typed value that isn't in the list, so a name you'll add later isn't blocked.

Adding backups

To add scheduled backups, extend service.yaml:

yaml
nodes:
  server:
    count: 1
    ports:
      - port: 6379
        from: private
    actions:
      install: scripts/install.js
      configure: scripts/configure.js
    schedule:
      backup:
        script: scripts/backup.js
        every: 6h

And write scripts/backup.js:

javascript
const managed = require("managed");
const params = managed.params;

// Trigger RDB save
managed.runCommand("redis-cli BGSAVE", { sudo: true });
managed.sleep(5000);

// Upload the dump. Key it under backups/<server>/ — the namespace the
// managed-backup retention library and the built-in packages' restore and
// backup-list scripts all share.
managed.storage.upload(
  "backups/" + params._serverName + "/" + managed.sh.timestamp() + ".rdb",
  "file:/var/lib/redis/dump.rdb"
);

managed.log.info("Redis backup uploaded");

That's all a schedule needs — there is no extra mechanism to hook into. At deploy time managed ships the package (and its deps libraries) to the node's server and installs a cron entry that runs the schedule's script on the box itself, through the same managed binary the agent uses. A cron run and a manual managed backup execute the exact same script with the same params, so storage keys and retention can't drift between the two. Script output lands in /var/log/managed-<service>-<schedule>.log on the server.

The platform provisions object storage automatically when it sees a schedule with backup scripts.

Adding replicas

For a service with replicas, add a second node role:

yaml
nodes:
  primary:
    count: 1
    ports:
      - port: 6379
        from: service
    actions:
      install: scripts/install_primary.js
      configure: scripts/configure_primary.js

  replica:
    count_from: replicas
    actions:
      install: scripts/install_replica.js
      configure: scripts/configure_replica.js

In the replica's configure script, use managed.state.getServer() to discover the primary's IP and set up replication.

Lifecycle options

A few optional fields on nodes/actions control how a service is placed, updated, and removed. They all degrade gracefully, omit them and nothing changes.

Run on every server (scope: fleet)

For a host agent (metrics, log shipper) that should run on every box, give its node scope: fleet instead of a count. Managed installs it on each server, provisions none of its own, and never takes ownership of the boxes, new servers pick it up automatically on the next apply.

yaml
nodes:
  agent:
    scope: fleet
    ports:
      - { port: 9100, protocol: tcp, from: private }
    actions:
      install:   scripts/install.js
      configure: scripts/configure.js
      verify:    scripts/verify.js
      uninstall: scripts/uninstall.js   # so it can be removed cleanly

Remove cleanly (uninstall)

When a service is removed from a box it doesn't own (a fleet/co-located service taken out of managed.yaml, or managed service destroy), managed runs its uninstall action. Use it to stop and remove the daemon; make every step idempotent. The engine removes firewall rules and state itself. Daemon and fleet packages should ship an uninstall script, without one, a removed service leaves its process running.

js
const managed = require("managed");
if (managed.systemd.isActive("myd")) managed.systemd.stop("myd");
managed.systemd.disable("myd");
managed.files.delete("/etc/systemd/system/myd.service", { sudo: true });
managed.systemd.daemonReload();
managed.files.delete("/usr/local/bin/myd", { sudo: true });

Rolling updates and config-change detection

The configure phase re-runs a node only when its config actually changed (a param, or a peer's internal IP). When several nodes of a role change at once, they update one at a time by default, with a readiness check between them, so a warm-up-sensitive role isn't all restarted together. Tune it with rolling_update: { max_unavailable, ready_timeout } on the node and depends_on: [roles] on the configure action.

See DESIGN_SERVICES.md (§F) for the full reference.