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"
}
}
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.
/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 });
}
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.