+1 (726) 207-9872

Writing Serverless Framework V4 Plugins and Variable Resolvers (And When Not To)

Every Serverless Framework service eventually hits something the core framework does not do: a secret that lives in an internal vault, a deployment gate that has to call your change-management system, a naming convention nobody will remember to follow by hand. The answer is almost always a plugin — and in V4, often a variable resolver rather than a plugin at all.

This is the guide we wish existed when we start plugin work on a client engagement: what the extension points actually are in V4, how to choose between them, and how to write and ship something your team can maintain for years.

The three extension points in V4

Serverless Framework V4 gives you three distinct hooks, and picking the wrong one is the most common mistake we see.

You want to...Use
Add a command, or run logic around deploy/package/removeA plugin with lifecycle hooks
Resolve ${something:key} in serverless.yml from a custom sourceA variable resolver
Add or rewrite CloudFormation resources before deploymentA plugin hooked on package:finalize

A variable resolver is a fraction of the code of a plugin and is resolved lazily, so a value nobody references never triggers a network call. If your need is "fetch a value from somewhere and paste it into config", stop reading about plugins and write a resolver.

Anatomy of a V4 plugin

A plugin is a class exported from a Node module. The constructor receives the serverless instance and the CLI options; you register commands and hooks on it.

// plugins/deploy-guard.js
class DeployGuard {
  constructor(serverless, options, { log, progress }) {
    this.serverless = serverless;
    this.options = options;
    this.log = log;
    this.progress = progress;

    this.hooks = {
      'before:deploy:deploy': () => this.assertChangeWindow(),
      'after:deploy:deploy': () => this.recordDeployment(),
    };

    this.commands = {
      'guard:check': {
        usage: 'Check whether a deploy is currently permitted',
        lifecycleEvents: ['check'],
        options: {
          force: { usage: 'Bypass the window check', type: 'boolean' },
        },
      },
    };

    this.hooks['guard:check:check'] = () => this.assertChangeWindow();
  }

  async assertChangeWindow() {
    const stage = this.serverless.service.provider.stage;
    if (stage !== 'prod' || this.options.force) return;

    const progress = this.progress.create({ message: 'Checking change window' });
    try {
      const allowed = await isWindowOpen();
      if (!allowed) {
        throw new this.serverless.classes.Error(
          'Production deploys are frozen outside the 09:00-16:00 UTC window. Re-run with --force and a ticket reference.',
          'DEPLOY_WINDOW_CLOSED'
        );
      }
      this.log.success('Change window open');
    } finally {
      progress.remove();
    }
  }

  async recordDeployment() {
    const { service, provider } = this.serverless.service;
    await postToChangeLog({ service, stage: provider.stage, region: provider.region });
  }
}

module.exports = DeployGuard;

Four things in there matter more than the rest:

  1. Use serverless.classes.Error, not throw new Error. Framework errors print cleanly with your message and code; plain errors print a stack trace and an invitation to file a bug against the framework.
  2. Use the injected log and progress utilities (the third constructor argument) instead of console.log. They respect --verbose, --debug and JSON output modes, and they do not corrupt the progress spinner.
  3. Hook names are before:/after: plus a lifecycle event. Run serverless deploy --verbose to see which events actually fire for your provider before guessing.
  4. Every custom command needs both a commands entry and a matching hook named command:lifecycleEvent.

Wire it up with a local path — no publishing required:

# serverless.yml
plugins:
  - ./plugins/deploy-guard.js

Mutating CloudFormation safely

The other big plugin use case is enforcing standards: tags on everything, log retention, no public buckets, a KMS key on every queue. Do this at package:finalize, after the framework has built the full template.

this.hooks['package:finalize'] = () => {
  const tpl = this.serverless.service.provider
    .compiledCloudFormationTemplate;

  for (const [name, res] of Object.entries(tpl.Resources)) {
    if (res.Type === 'AWS::Logs::LogGroup' && !res.Properties.RetentionInDays) {
      res.Properties.RetentionInDays = 30;
      this.log.notice(`Set 30-day retention on ${name}`);
    }
    if (res.Type === 'AWS::SQS::Queue') {
      res.Properties.KmsMasterKeyId ??= 'alias/aws/sqs';
    }
  }
};

Two rules keep this from becoming a nightmare:

  • Never delete resources someone else created. Add and default; do not remove. A plugin that silently drops a resource produces a CloudFormation diff nobody can explain at 2 a.m.
  • Be idempotent. package can run more than once in a single process (Compose, tests). Use ??= and guard clauses so a second pass is a no-op.

To see the effect, run serverless package and diff .serverless/cloudformation-template-update-stack.json before and after enabling the plugin. Make that diff part of code review.

Variable resolvers: the underused half of V4

V4 lets you register a resolver for your own variable prefix. Suppose secrets live in HashiCorp Vault rather than SSM:

// plugins/vault-resolver.js
class VaultResolver {
  constructor(serverless) {
    this.configurationVariablesSources = {
      vault: {
        async resolve({ address, options, resolveConfigurationProperty }) {
          const stage =
            options.stage ||
            (await resolveConfigurationProperty(['provider', 'stage'])) ||
            'dev';
          const [path, key] = address.split(':');
          const secret = await readVault(`${stage}/${path}`);
          if (secret?.[key] === undefined) {
            throw new Error(`vault: no key "${key}" at "${stage}/${path}"`);
          }
          return { value: secret[key] };
        },
      },
    };
  }
}

module.exports = VaultResolver;

Now serverless.yml reads naturally, and nothing is fetched unless it is referenced:

provider:
  environment:
    STRIPE_KEY: ${vault:payments/stripe:secret_key}

A word of caution that applies to any resolver: resolved values land in the CloudFormation template. Anything you resolve at deploy time into environment is visible to anyone with lambda:GetFunctionConfiguration. For genuinely sensitive material, resolve an ARN or a secret name at deploy time and fetch the value at runtime with the AWS Parameters and Secrets Lambda Extension, which caches it in the execution environment. See our notes on least-privilege IAM and secrets management for the full pattern.

Testing a plugin

Plugins are ordinary Node modules, so test them like ordinary Node modules. Construct the class with a stub serverless object and invoke the hook directly:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import DeployGuard from '../plugins/deploy-guard.js';

const fakeServerless = (stage) => ({
  service: { provider: { stage }, service: 'orders' },
  classes: { Error: class extends Error {} },
});
const utils = { log: { success() {}, notice() {} },
                progress: { create: () => ({ remove() {} }) } };

test('non-prod stages skip the window check', async () => {
  const p = new DeployGuard(fakeServerless('dev'), {}, utils);
  await assert.doesNotReject(p.hooks['before:deploy:deploy']());
});

For template-mutating plugins, assert on the compiled template object rather than snapshotting the whole thing — a full snapshot breaks on every unrelated framework upgrade. For end-to-end confidence, add one serverless package run in CI and assert on specific JSON paths.

Distributing it to more than one team

Once two services need the plugin, stop copying files.

  • Publish to a private npm registry (CodeArtifact, GitHub Packages) as @yourco/serverless-deploy-guard. Pin it with a caret range and let Renovate open the upgrade PRs.
  • Declare a peer dependency on the framework major version, and say plainly in the README which V4 versions you test against.
  • Version the behaviour, not just the code. Tightening a rule — adding a new mandatory tag — is a breaking change for the teams who deploy with it. Ship it as a major, and give it an escape hatch (custom.deployGuard.enforceTags: false) so nobody is blocked at release time.
  • Log what you changed. A plugin that silently rewrites templates erodes trust fast; one log.notice per mutation costs nothing and makes the behaviour auditable.

When not to write a plugin

Plugin code is deploy-path code: when it breaks, nobody ships. Before writing one, check whether the job belongs somewhere cheaper.

  • A CI step is usually better for anything that does not need the framework's internal state. Change-window checks, drift detection and post-deploy smoke tests are fine as plain scripts in GitHub Actions.
  • Service-Level Config / shared YAML fragments handle "every service should set these defaults" without any code, and they are far easier to debug.
  • An existing plugin may already do it. serverless-esbuild, serverless-offline, serverless-domain-manager and serverless-step-functions cover most common needs — but check the commit history. An unmaintained plugin in your deploy path is a liability; vendoring a 200-line fork you understand beats depending on an abandoned 4,000-line one.
  • Organisation-wide guardrails belong in AWS, not the framework. Service Control Policies, CloudFormation Hooks and permissions boundaries apply no matter who deploys or how — a Serverless Framework plugin only constrains people who use Serverless Framework.

A short checklist

Before you merge a plugin into the deploy path:

  • Could this be a variable resolver, a CI step, or shared config instead?
  • Does it throw serverless.classes.Error with an actionable message?
  • Is it idempotent across repeated package runs?
  • Does it only add and default, never delete?
  • Does it have unit tests plus one serverless package assertion in CI?
  • Is there an off-switch in custom: for the day it misfires?
  • Is it versioned, pinned, and owned by a named team?

Get those right and a plugin stops being a clever trick and becomes infrastructure: the place your standards live, applied automatically, the same way in every service.


Need help? SleekDeploy builds and maintains Serverless Framework tooling for platform teams — custom plugins, variable resolvers, shared config and the CI pipelines around them. Get in touch or read more about our DevOps for Serverless Apps practice.