<< Home

Chapter 19: Packaging and Deployment

Getting your TypeScript and Node projects into users' hands requires thoughtful packaging and dependable deployment. This chapter walks through command line packaging, container images, serverless and edge targets, and release automation that produces repeatable, auditable artifacts.

CLI apps and shebangs

Command line tools start like any other Node program; the difference is how the entry file is invoked and distributed. A shebang allows a script to run as an executable on Unix like systems. Packaging with npm also needs a bin map so installs can place shims on the user's PATH.

Entry file layout

Place your CLI's entry in src/ and compile to dist/. Keep parsing, I/O, and business logic in separate modules so you can test logic without invoking the process layer.

Adding a shebang safely

Use #!/usr/bin/env node for portability. When compiling TypeScript, output the shebang in the built file; do not rely on the TypeScript compiler to copy comments unless configured.

#!/usr/bin/env node
import { main } from "./cli-main.js";

main(process.argv).catch(err => {
  console.error(err instanceof Error ? err.message : String(err));
  process.exit(1);
});

Declaring bin in package.json

Map one or more command names to built files. Local development is easiest with npm link or pnpm link.

{
  "name": "@acme/greeter",
  "type": "module",
  "bin": {
    "greeter": "./dist/cli.js"
  },
  "scripts": {
    "build": "tsc -p tsconfig.build.json"
  }
}
💡 On Unix like systems set the executable bit with chmod +x dist/cli.js. Windows does not use executable bits; npm creates a .cmd shim that forwards to Node.

Packaging with npm pack

npm pack creates a tarball that matches what would be published. Add a .npmignore or use files in package.json to include only necessary artifacts.

Docker images and multi stage builds

Containers provide a reproducible runtime with system level dependencies. A multi stage build reduces image size by compiling in one stage and copying only the runtime files into a minimal base image.

Choosing a base image

Use an LTS Node image for builds and a smaller base for runtime such as gcr.io/distroless/nodejs or an Alpine variant. Validate native dependencies before switching bases.

A minimal multi stage Dockerfile

This example installs dependencies with a clean install, builds TypeScript, and copies only production files into the final stage.

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev

FROM gcr.io/distroless/nodejs22
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER 1000
EXPOSE 3000
CMD ["dist/server.js"]

Reproducible builds

Pin exact versions in your lockfile and prefer npm ci rather than npm install. Enable Docker layer caching with separate COPY steps so dependency layers are reused.

⚠️ Distroless images lack a shell. Add runtime diagnostics to your app such as a /health endpoint and structured logs; do not expect to sh into the container.

Serverless targets and edge runtimes

Serverless platforms execute functions in response to events; edge runtimes run JavaScript close to users with a browser like API. Both have constraints around cold starts, startup size, and available Node modules.

Packaging a function handler

Export a small handler surface and keep imports shallow so the platform bundles minimal code. Avoid large optional dependencies.

export async function handler(event: { path: string; body: string | null }) {
  const start = Date.now();
  const result = await route(event.path, event.body);
  return { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ result, took: Date.now() - start }) };
}

Edge compatible code

Edge environments often expose fetch, Request, and Response rather than Node's http module. Use Web APIs when possible and guard any Node specific imports with conditional code.

export default async function handle(request: Request): Promise<Response> {
  const url = new URL(request.url);
  if (url.pathname === "/") return new Response("ok", { status: 200 });
  return new Response("not found", { status: 404 });
}
💡 Tree shaking reduces bundle size. Prefer import { specific } over importing whole libraries; verify with your bundler's analyze report.

Environment and secrets

Use platform specific secret stores rather than plaintext environment files. Keep configuration injection simple; prefer process.env.NAME ?? "default" and validate at startup.

Versioning, releases, and automation

Reliable releases use semantic versioning, repeatable changelog generation, and automated pipelines that build, test, and publish artifacts on tagged commits. The same commit should produce the same package and image.

Semantic versioning

Increment major for breaking changes, minor for new features, and patch for fixes. Communicate deprecations clearly in the changelog and keep the public API surface explicit.

Automated release pipeline

This example workflow runs tests, builds, publishes a package to the registry, and pushes a Docker image tagged with the version. Adjust registry logins and permissions for your environment.

name: release
on:
  push:
    tags:
      - "v*.*.*"
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - run: npm test --if-present
      - run: npm run build
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - run: |
          VERSION=${GITHUB_REF_NAME#v}
          docker build -t acme/app:$VERSION .
          docker push acme/app:$VERSION

Changelogs and provenance

Generate changelogs from conventional commits so readers can scan changes quickly. Enable provenance or attestations where your tooling supports it so consumers can verify how the artifact was built.

💡 Tag from the commit you intend to release; never build a release from a working tree with uncommitted files. A clean workspace makes releases predictable.