Dev Automation Toolkit with zx

Build cross-platform TypeScript scripts that automate builds and Docker services.

Introduction

30 Second Summary

Every developer has a collection of shell scripts that automate tedious tasks. They work perfectly on your machine. Then a teammate on a different operating system tries to run them and everything breaks.

In this project, you will build a developer automation toolkit using zx and TypeScript. Your scripts will automate system checks, file processing, parallel builds, and Docker container orchestration, all without a single platform-specific hack.

What You'll Build

You'll run a script that spins up a Docker container, watches a spinner animate while it polls for health, then prints a live URL and tears down the service cleanly. Hand that same script to a teammate on macOS or Linux and it runs without modification.

By the end of this project, you'll have:

  • A system check script that detects your entire toolchain, demonstrates safe argument escaping, and captures failing commands gracefully.
  • A cross-platform file processor that discovers, reads, and copies files across directories using pure JavaScript utilities instead of shell commands.
  • A parallel build pipeline that type-checks and compiles TypeScript simultaneously with visual spinner feedback and elapsed-time reporting.
  • A Docker orchestration script with retry-based health checks, container inspection, and clean teardown.
  • Secret Mission: Add CLI argument parsing and multi-container orchestration with parallel startup and independent health checks.

Are there any prerequisites?

You should be comfortable with TypeScript and have Node.js v26, Docker Desktop, and VS Code installed on your machine. Step 1 walks you through installing zx and setting up the project structure.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project

Every automation toolkit starts with a solid foundation. You need a project directory, a TypeScript configuration, and the right dependencies installed before you can write your first script.

In this step, you will scaffold the project, install zx as your scripting engine, and create a dummy app that your automation scripts will target in later steps.

In this step, get ready to:
  • Initialize an npm project and install zx with TypeScript support.
  • Create a TypeScript configuration and a minimal dummy app to automate against.
  • Verify zx and TypeScript are working correctly.
Initialize the project and install dependencies
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir automation-toolkit
cd automation-toolkit
  • Initialize a new npm project and install the dependencies by running these commands:
npm init -y
npm install --save-dev zx@8.8.5 typescript @types/node @types/fs-extra

You should see output ending with something like added 4 packages confirming the packages installed successfully.

What did that install?

  • zx is Google's shell scripting toolkit for JavaScript/TypeScript. It wraps Node's child_process with safe escaping and a clean template-literal syntax.
  • typescript is the TypeScript compiler your dummy app needs.
  • @types/node and @types/fs-extra give VS Code autocomplete for zx's bundled utilities.

Seeing permission errors or install failures?

Make sure you are inside the automation-toolkit folder before running the install command. Run pwd to confirm your current directory ends with automation-toolkit.

If npm cannot reach the registry, check your internet connection and try again.

Help me debug npm install errors in my automation-toolkit project.

The npm init -y command created a basic package.json, but it needs two additions: an ES module declaration and script shortcuts for your automation scripts.

  • Open the automation-toolkit folder in VS Code by running:
code .
  • In VS Code's Explorer sidebar, click package.json to open it.
  • Select all the existing content and replace it with the following:
{
  "name": "automation-toolkit",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "check": "npx zx scripts/check.ts",
    "process": "npx zx scripts/process-files.ts",
    "build": "npx zx scripts/build.ts",
    "docker": "npx zx scripts/docker-up.ts"
  },
  "devDependencies": {
    "zx": "8.8.5",
    "typescript": "5.8.3",
    "@types/node": "22.15.21",
    "@types/fs-extra": "11.0.4"
  }
}

What does this package.json do?

  • "type": "module" tells Node.js to treat .js files as ES modules. This is required for zx to work with TypeScript.
  • The scripts section creates shortcuts so you can run npm run check instead of typing the full npx zx scripts/check.ts command.
  • The pinned version numbers under devDependencies ensure everyone on the team installs the exact same versions.
  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
  • Reinstall from the pinned versions by running this in the VS Code terminal:
npm install

You should see up to date or a brief install confirmation. Your dependencies are now locked to exact versions.

Create the project structure

Your automation scripts will target a small TypeScript app that lives in src/. The scripts themselves will go in a separate scripts/ directory.

  • Create both directories by running this command in the VS Code terminal:
mkdir src scripts
  • In VS Code, right-click in the Explorer sidebar and select New File.
  • Name the file tsconfig.json and paste the following content:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

What does tsconfig.json configure?

  • "target": "ES2022" compiles to modern JavaScript that Node.js v26 fully supports.
  • "module": "ESNext" produces ES module output, matching the "type": "module" setting in your package.json.
  • "include": ["src/**/*"] tells the compiler to only check files in the src/ directory. Your automation scripts in scripts/ are handled by zx separately.
  • Save tsconfig.json.
  • Right-click the src folder in the Explorer sidebar and select New File.
  • Name the file index.ts and paste the following content:
import { greet, getTimestamp } from './utils.js'

const message = greet('Automation Toolkit')
console.log(`[${getTimestamp()}] ${message}`)

What does this file do?

This is the entry point of your dummy app. It imports two helper functions and logs a timestamped greeting. Your automation scripts will compile and run this file to prove the build pipeline works.

  • Save src/index.ts.
  • Confirm your project structure so far by running:
ls src

You should see index.ts listed in the output.

  • Right-click the src folder again and create another new file named utils.ts.
  • Paste the following content:
export function greet(name: string): string {
  return `Hello from ${name}!`
}

export function getTimestamp(): string {
  return new Date().toISOString()
}

What does utils.ts provide?

These two exported functions give your dummy app something to import and call. The greet function returns a greeting string, and getTimestamp returns the current time in ISO format.

  • Save src/utils.ts.

Don't see the src folder in the sidebar?

Make sure you opened the automation-toolkit folder in VS Code (not a parent folder). The Explorer sidebar should show package.json, tsconfig.json, src/, and scripts/ at the top level.

Help me fix my project folder structure in VS Code.

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these files with matching content:

  • package.json with "type": "module", four script shortcuts, and the pinned devDependencies.
  • tsconfig.json targeting ES2022/ESNext with outDir set to dist.
  • src/index.ts importing from ./utils.js and logging a timestamped greeting.
  • src/utils.ts exporting greet and getTimestamp functions.
  • An empty scripts/ directory.
Verify the setup

Before you write any automation, confirm that both zx and the TypeScript compiler are working.

  • Check that zx is installed by running:

Before you run this, what version number do you expect to see based on the version you pinned in package.json?

npx zx --version

You should see 8.8.5 printed in the terminal.

  • Verify that TypeScript can compile your dummy app without errors by running:
npx tsc --noEmit

This command should produce no output at all. Silence means success. TypeScript found your src/ files and confirmed they have no type errors.

Seeing type errors or 'command not found'?

  • If npx zx --version fails, check that node_modules/ exists in your project folder. If not, run npm install again.
  • If npx tsc --noEmit shows errors about missing modules, make sure src/utils.ts exists and exports both greet and getTimestamp.
  • If you see Cannot find module './utils.js', confirm that src/index.ts imports from './utils.js' (with the .js extension, not .ts).

Help me fix TypeScript compilation errors in my automation-toolkit project.

Your project skeleton is ready. You have zx installed, TypeScript configured, and a working dummy app to automate against. Next up, you will write your first zx script and feel the difference from raw child_process code.

Write Your First zx Script

Your project is scaffolded and zx is confirmed working. Now it's time to write actual automation and feel how zx differs from raw child_process.

In this step, you'll create your first script that detects every tool in your dev environment and prints a clean report. Along the way, you'll see how zx handles argument escaping and graceful error capture.

In this step, get ready to:
  • Create a system check script that captures tool versions with zx's $ syntax.
  • Demonstrate safe argument escaping that prevents shell injection.
  • Use .nothrow() to gracefully capture a failing command without crashing.
Create the system check script

The $ tagged template literal is how you run shell commands in zx. It returns an object with .stdout, .stderr, and .exitCode properties.

  • Create a new file called scripts/check.ts inside your automation-toolkit folder and paste the following code:
import 'zx/globals'

usePowerShell()

console.log(chalk.bold('Automation Toolkit - System Check'))
console.log(chalk.gray('-'.repeat(40)))

const nodeVersion = await $`node --version`
console.log(chalk.green(`Node.js: ${nodeVersion.stdout.trim()}`))

const npmVersion = await $`npm --version`
console.log(chalk.green(`npm:     ${npmVersion.stdout.trim()}`))

const gitVersion = await $`git --version`
console.log(chalk.green(`Git:     ${gitVersion.stdout.trim()}`))

const dockerVersion = await $`docker --version`
console.log(chalk.green(`Docker:  ${dockerVersion.stdout.trim()}`))

What does this code do?

import 'zx/globals' loads all of zx's utilities ($, chalk, fs, path, etc.) into the global scope so you can use them without individual imports.

usePowerShell() tells zx to use PowerShell as its shell backend on Windows. This ensures your commands execute correctly on Windows while keeping the same script portable to other operating systems.

Each await $`...` call runs a shell command and waits for it to finish. The result's .stdout.trim() gives you the command's output with whitespace cleaned up.

  • Save scripts/check.ts.
  • Run the script to see your tool versions by running:
npx zx scripts/check.ts

You should see green text listing your Node.js, npm, Git, and Docker versions.

Seeing an error about bash not found?

If you see a message about bash being missing, make sure usePowerShell() is the very first function call in your script (right after the import). Without it, zx defaults to looking for bash, which may not be on your PATH.

help me fix a bash not found error when running my zx script on Windows

Add safe escaping and error handling

Shell injection is a real risk when passing user input to commands. zx automatically quotes anything you interpolate into a $ template, so special characters like &, (, and spaces are handled safely.

  • In scripts/check.ts, add the following code below the Docker version line:
const projectName = 'my project (special chars & spaces)'
const escaped = await $`echo ${projectName}`
console.log(chalk.blue(`\nSafe escaping: ${escaped.stdout.trim()}`))

const failing = await $`node -e "process.exit(42)"`.nothrow()
console.log(chalk.yellow(`Failed command captured (exit ${failing.exitCode}) - no crash!`))

console.log(chalk.bold.green('\nAll tools detected. Toolkit is ready.'))

What does this code do?

The projectName string contains characters that would normally break a shell command (& is a command separator, parentheses have special meaning). When you pass it through ${projectName} inside a $ template, zx automatically escapes and quotes it so it reaches the command intact.

.nothrow() changes the default behavior where zx throws an exception on non-zero exit codes. Instead, the process output is returned normally so you can inspect .exitCode yourself. This is essential for scripts that need to check whether a tool exists without crashing.

  • Save scripts/check.ts.

Before you run the script again, what do you expect to see after the version list?

  • Run the full script again:
npx zx scripts/check.ts

You should see the version report followed by a blue line showing the string printed intact with all its special characters, a yellow line showing exit code 42 was captured without crashing, and a green confirmation that all tools are detected.

Script crashes instead of capturing the exit code?

Make sure .nothrow() is chained directly on the $ template literal with no space or line break in between. If you forgot .nothrow(), zx throws on any non-zero exit code by default.

help me fix my zx script that crashes instead of capturing a non-zero exit code

✔️ Awesome, I've got everything!

Great. Double check you've saved scripts/check.ts and move on.

ⓧ I'd like to double check the full code

Here is the complete scripts/check.ts file:

import 'zx/globals'

usePowerShell()

console.log(chalk.bold('Automation Toolkit - System Check'))
console.log(chalk.gray('-'.repeat(40)))

const nodeVersion = await $`node --version`
console.log(chalk.green(`Node.js: ${nodeVersion.stdout.trim()}`))

const npmVersion = await $`npm --version`
console.log(chalk.green(`npm:     ${npmVersion.stdout.trim()}`))

const gitVersion = await $`git --version`
console.log(chalk.green(`Git:     ${gitVersion.stdout.trim()}`))

const dockerVersion = await $`docker --version`
console.log(chalk.green(`Docker:  ${dockerVersion.stdout.trim()}`))

const projectName = 'my project (special chars & spaces)'
const escaped = await $`echo ${projectName}`
console.log(chalk.blue(`\nSafe escaping: ${escaped.stdout.trim()}`))

const failing = await $`node -e "process.exit(42)"`.nothrow()
console.log(chalk.yellow(`Failed command captured (exit ${failing.exitCode}) - no crash!`))

console.log(chalk.bold.green('\nAll tools detected. Toolkit is ready.'))

You now have a working zx script that detects your toolchain and handles edge cases safely. Next up, you'll hit the cross-platform wall and learn why shell commands alone aren't enough for portable scripts.

Solve the Cross-Platform File Problem

In the previous step, your check script called tools like node, npm, and git that exist on every operating system. Real automation scripts go further. They need to find files, create directories, and copy data around.

Shell commands for file operations are where cross-platform scripts break. The find command is a classic trap. On macOS and Linux it searches for files by name and type. On Windows, find is a completely different tool that searches for text strings inside files. You're about to hit that wall and learn the fix.

In this step, get ready to:
  • Write a shell-based file discovery script and watch it break on Windows.
  • Replace shell commands with zx's JavaScript-level utilities that work on every OS.
  • Run the fixed script to confirm portable file processing.
Write a naive file discovery attempt

Your first instinct for finding files in a script might be to reach for the find shell command. Let's try that approach and see what happens.

  • Create a new file called scripts/process-files.ts and paste in this code:
import 'zx/globals'

usePowerShell()

console.log(chalk.bold('Cross-Platform File Processor'))
console.log(chalk.gray('-'.repeat(40)))

// Try to find TypeScript files using the shell find command
const result = await $`find . -name "*.ts" -type f`
console.log(result.stdout)

What does this code do?

This script uses the shell find command to search for all .ts files in the current directory tree. On macOS or Linux, this would work perfectly. On Windows with usePowerShell(), it invokes a completely different program.

Before you run this, what do you think will happen? Will the find command behave the way you expect on Windows?

  • Run the script to see what happens:
npx zx scripts/process-files.ts

The script crashes. On Windows, you'll see an error containing the message FIND: Parameter format not correct. Windows' find.exe is a text-search tool that expects entirely different syntax. It has no idea what -name or -type means.

Why does this fail?

On macOS and Linux, find is a file-search utility. On Windows, find is a text-search utility (like grep). Same name, completely different programs. This is the cross-platform wall.

The fix is to avoid shell commands for file operations entirely. Instead, use JavaScript-level utilities that work identically on every OS. That's exactly what zx provides through glob, fs, and path.

Replace shell commands with cross-platform zx utilities

Now you know shell-based file operations break across platforms. The solution is to use zx's bundled JavaScript utilities that run at the language level, not the shell level.

  • Replace the entire contents of scripts/process-files.ts with this file discovery code:
import 'zx/globals'

usePowerShell()

console.log(chalk.bold('Cross-Platform File Processor'))
console.log(chalk.gray('-'.repeat(40)))

// Discover TypeScript files using glob (works on every OS)
const tsFiles = await glob(['src/**/*.ts'])
console.log(chalk.blue(`\nDiscovered ${tsFiles.length} TypeScript files:`))
for (const file of tsFiles) {
  const content = await fs.readFile(file, 'utf-8')
  const lineCount = content.split('\n').length
  console.log(chalk.gray(`  ${file} (${lineCount} lines)`))
}

What does this code do?

  • glob(['src/**/*.ts']) searches for files using JavaScript pattern matching instead of a shell command. It works identically on Windows, macOS, and Linux.
  • fs.readFile() reads each file's content so we can count its lines. This is zx's bundled fs-extra package.
  • Save scripts/process-files.ts and run the script to confirm file discovery works:
npx zx scripts/process-files.ts

You should see output listing your two TypeScript files with their line counts. No errors this time.

Still seeing an error?

  • Make sure you replaced the entire file contents. The old find line should be gone.
  • Check that your src/ folder still contains index.ts and utils.ts. The glob pattern searches inside src/.
  • help me debug my process-files script

Now let's add the file processing logic. Shell scripts typically use mkdir -p and cp to create directories and copy files. Both are platform-specific. zx gives you fs.ensureDir() and fs.copy() instead.

  • Add this code below the existing file discovery loop in scripts/process-files.ts:
// Create output directory (replaces mkdir -p)
const outputDir = path.join('dist', 'processed')
await fs.ensureDir(outputDir)
console.log(chalk.green(`\nCreated: ${outputDir}`))

// Copy files to output (replaces cp)
for (const file of tsFiles) {
  const dest = path.join(outputDir, path.basename(file))
  await fs.copy(file, dest)
  console.log(chalk.gray(`  Copied: ${path.basename(file)}`))
}

const outputFiles = await glob(['dist/processed/*.ts'])
console.log(chalk.bold.green(`\nDone! ${outputFiles.length} files processed to ${outputDir}`))

What does this code do?

  • path.join('dist', 'processed') builds file paths with the correct separator for the current OS. No hardcoded slashes.
  • fs.ensureDir() creates the directory (and any parent directories) if they don't exist. This replaces mkdir -p on Unix and New-Item -ItemType Directory on Windows.
  • fs.copy() copies files without needing cp or Copy-Item.
  • The final glob() verifies the output directory actually contains the copied files.
  • Save scripts/process-files.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it.

ⓧ I'd like to double check the full code

import 'zx/globals'

usePowerShell()

console.log(chalk.bold('Cross-Platform File Processor'))
console.log(chalk.gray('-'.repeat(40)))

const tsFiles = await glob(['src/**/*.ts'])
console.log(chalk.blue(`\nDiscovered ${tsFiles.length} TypeScript files:`))
for (const file of tsFiles) {
  const content = await fs.readFile(file, 'utf-8')
  const lineCount = content.split('\n').length
  console.log(chalk.gray(`  ${file} (${lineCount} lines)`))
}

const outputDir = path.join('dist', 'processed')
await fs.ensureDir(outputDir)
console.log(chalk.green(`\nCreated: ${outputDir}`))

for (const file of tsFiles) {
  const dest = path.join(outputDir, path.basename(file))
  await fs.copy(file, dest)
  console.log(chalk.gray(`  Copied: ${path.basename(file)}`))
}

const outputFiles = await glob(['dist/processed/*.ts'])
console.log(chalk.bold.green(`\nDone! ${outputFiles.length} files processed to ${outputDir}`))
Run the fixed script and verify output

Your script now uses zero shell commands for file operations. Every file action goes through JavaScript-level utilities that behave identically on Windows, macOS, and Linux.

Before you run this, what do you expect to see? Think about how many files the script should discover and copy.

  • Run the complete file processor:
npx zx scripts/process-files.ts

You should see output showing 2 TypeScript files discovered with their line counts, the dist/processed directory created, both files copied, and a final summary confirming 2 files processed.

  • Confirm the output files exist by running:
ls dist/processed

You should see index.ts and utils.ts listed in the output.

Not seeing the processed files?

  • Make sure you added the second code block below (not inside) the first for loop. The closing } of the first loop should come before const outputDir.
  • Check that the glob pattern in the final line matches dist/processed/*.ts exactly.
  • help me fix my file processing script

Why this approach instead of shell commands?

Every file operation in this script (glob, fs.readFile, fs.ensureDir, fs.copy, path.join) runs in JavaScript, not in the shell. There's no find, mkdir, cp, or cat call that could behave differently on another OS.

You could hand this script to a teammate on macOS or Linux and it would run without modification. That's the principle: use $ for universal CLI tools (node, docker, git) and JavaScript utilities for everything else.

Your toolkit now handles file operations without a single platform-specific shell command. Next up, you'll tackle a real build pipeline with parallel tasks and visual feedback.

Build a Parallel Build Pipeline

Your file processing script now works across any operating system. The next challenge is speed.

Real build pipelines have multiple independent tasks: type checking, file discovery, and compilation. Running them one after another wastes time. Promise.all lets you run independent tasks simultaneously, and spinner() gives visual feedback while long operations run.

In this step, get ready to:
  • Run type checking and file discovery in parallel with Promise.all.
  • Compile TypeScript with a visual spinner for feedback.
  • Display a build summary with file counts and elapsed time.
Create the parallel checks

Type checking and file discovery are completely independent of each other. There is no reason to wait for one to finish before starting the other.

  • Create a new file scripts/build.ts in VS Code and paste the following code:
import 'zx/globals'

usePowerShell()

console.log(chalk.bold('Parallel Build Pipeline'))
console.log(chalk.gray('-'.repeat(40)))

const startTime = Date.now()

console.log(chalk.blue('\nRunning parallel checks...'))
const [typeCheck, sourceFiles] = await Promise.all([
  $`npx tsc --noEmit`.nothrow(),
  glob(['src/**/*.ts']),
])

if (typeCheck.exitCode !== 0) {
  console.log(chalk.red('Type check failed:'))
  console.log(typeCheck.stdout)
  process.exit(1)
}
console.log(chalk.green('  Type check: passed'))
console.log(chalk.green(`  Source files: ${sourceFiles.length} found`))

What does this code do?

Promise.all starts both tasks at the same time and waits for both to finish. The type check runs npx tsc --noEmit (which validates types without producing output files), while glob discovers all .ts files in src/.

The .nothrow() on the type check prevents zx from crashing the entire script if there is a type error. Instead, you check exitCode manually and display a helpful error message before exiting.

  • Save scripts/build.ts.
  • Run the script to test the parallel checks by running this command:
npx zx scripts/build.ts

You should see "Type check: passed" and "Source files: 2 found" printed in green. The script then crashes because the compilation code does not exist yet.

Seeing a type check failure?

If the script reports "Type check failed", open src/index.ts and src/utils.ts and check for typos. The import path in index.ts must be './utils.js' (with the .js extension) for ESM resolution to work.

help me debug a TypeScript type check failure in my zx build script

Add compilation with spinner feedback

The parallel checks confirm the code is valid. Now you need the actual compilation step that produces JavaScript output files.

  • In scripts/build.ts, add the following code below the last console.log line (the one printing source files found):
await spinner('Compiling TypeScript...', () =>
  $`npx tsc --outDir dist`
)

const outputFiles = await glob(['dist/**/*.js'])
const elapsed = Date.now() - startTime

console.log(chalk.bold.green(`\nBuild complete in ${elapsed}ms`))
console.log(chalk.gray(`  Input:  ${sourceFiles.length} .ts files`))
console.log(chalk.gray(`  Output: ${outputFiles.length} .js files in dist/`))

What does this code do?

spinner() displays an animated spinner in the terminal while the callback runs. This gives the learner visual feedback that compilation is happening, instead of a blank terminal.

After compilation finishes, glob(['dist/**/*.js']) counts the output files. The elapsed time calculation uses the startTime captured at the very top of the script, so it includes both the parallel checks and the compilation.

  • Save scripts/build.ts.

Before you run this, what do you expect the output file count to be? Think about how many .ts files exist in src/ and what TypeScript compilation produces.

  • Run the complete build pipeline by running this command:
npx zx scripts/build.ts

You should see the parallel checks pass, a spinner animate during compilation, and then a summary showing 2 input .ts files, 2 output .js files in dist/, and the total elapsed time in milliseconds.

Spinner not showing or build errors?

If you see a "Cannot find module" error, make sure the import 'zx/globals' line is at the very top of your file. If the spinner does not animate, that is normal in some CI environments but should work in a standard terminal.

help me fix my zx build script that fails during the spinner compilation step

  • Confirm the compiled files exist by running:
ls dist

You should see index.js and utils.js listed in the output. These are the compiled JavaScript files produced by TypeScript.

✔️ Awesome, I've got everything!

Great. Double check you have saved scripts/build.ts and that both chunks are present in order.

ⓧ I'd like to double check the full code

Your scripts/build.ts has two sections. Scroll up and confirm:

  • The first section starts with import 'zx/globals' and ends with the console.log printing source files found.
  • The second section starts with await spinner('Compiling TypeScript...' and ends with the output summary lines.
  • Both sections together form the complete file with no gaps.

Your build pipeline now runs independent checks simultaneously and gives real-time spinner feedback during compilation. Next up, you will orchestrate a Docker container with health checks and retry logic.

Orchestrate Docker Services

Your build pipeline runs type-checking and compilation in parallel with live feedback.

Now it's time to tackle a task that really pushes shell scripting to its limits: orchestrating Docker containers. Starting a container, polling it until it's healthy, and tearing it down cleanly involves multiple async operations that must happen in sequence. With raw child_process, this turns into deeply nested callbacks or fragile promise chains.

In this step, get ready to:
  • Spin up an nginx container and verify it starts.
  • Health-check the container using retry logic wrapped in a spinner.
  • Tear down the container cleanly so nothing is left running.
Create the Docker orchestration script

This script needs to handle the full container lifecycle: clean up any leftover container from a previous run, start a fresh one, and confirm it's running.

  • Create a new file called scripts/docker-up.ts and add the following code:
import 'zx/globals'

usePowerShell()

const CONTAINER_NAME = 'toolkit-nginx'
const HOST_PORT = 8080

console.log(chalk.bold('Docker Service Orchestrator'))
console.log(chalk.gray('-'.repeat(40)))

await $`docker rm -f ${CONTAINER_NAME}`.nothrow().quiet()

console.log(chalk.blue(`\nStarting ${CONTAINER_NAME}...`))
await $`docker run -d --name ${CONTAINER_NAME} -p ${HOST_PORT}:80 nginx:alpine`
console.log(chalk.green('Container started.'))

What does this code do?

  • CONTAINER_NAME and HOST_PORT define constants so the script is easy to reconfigure later.
  • docker rm -f force-removes any leftover container from a previous run. .nothrow().quiet() means the script won't crash if no container exists and won't print noisy output.
  • docker run -d starts the container in detached mode, mapping port 8080 on your machine to port 80 inside the nginx container.
  • Save scripts/docker-up.ts.
  • Make sure Docker Desktop is running, then run this script:
npx zx scripts/docker-up.ts

You should see "Container started." printed in green. The nginx container is now running in the background.

Seeing a Docker error?

  • Make sure Docker Desktop is running. You can confirm by checking for the Docker icon in your system tray.
  • If you see a port conflict error, another process might be using port 8080. Stop it or change HOST_PORT to another value like 8081.
  • If you see a permission denied error, make sure your user has access to Docker (on Linux, your user needs to be in the docker group).

Why won't my Docker container start?

Add health checking with retry and spinner

A container starting doesn't mean the service inside is ready to accept requests. You need to poll it until it responds, with a timeout so the script doesn't hang forever.

Why fetch() instead of curl?

On Windows, curl is actually an alias for Invoke-WebRequest in PowerShell, which has completely different behaviour. JavaScript's fetch() works identically on every platform.

  • In scripts/docker-up.ts, add the following code below the console.log(chalk.green('Container started.')) line:
await spinner('Waiting for service to respond...', () =>
  retry(10, '1s', async () => {
    const response = await fetch(`http://localhost:${HOST_PORT}`)
    if (!response.ok) throw new Error('Not ready')
  })
)

console.log(chalk.green(`\nService is live at http://localhost:${HOST_PORT}`))

const inspect = await $`docker inspect ${CONTAINER_NAME}`.quiet()
const containerInfo = JSON.parse(inspect.stdout)
const state = containerInfo[0].State

console.log(chalk.gray(`\n  Status:  ${state.Status}`))
console.log(chalk.gray(`  Started: ${state.StartedAt}`))

What does this code do?

  • spinner() shows an animated spinner in the terminal while the callback runs, giving visual feedback that something is happening.
  • retry(10, '1s', ...) calls the callback up to 10 times with a 1-second delay between attempts. If fetch() gets a non-OK response, it throws and retries.
  • docker inspect returns detailed JSON about the container. You parse it to extract the State.Status and State.StartedAt fields.
  • Save scripts/docker-up.ts. You'll verify this chunk together with the teardown in the next section.
Implement clean teardown

A script that starts containers without stopping them leaves ghost processes consuming resources. The final piece stops and removes the container so nothing is left behind.

  • In scripts/docker-up.ts, add the following code at the very end of the file:
console.log(chalk.blue('\nTearing down...'))
await $`docker stop ${CONTAINER_NAME}`.quiet()
await $`docker rm ${CONTAINER_NAME}`.quiet()
console.log(chalk.bold.green('Service stopped and removed. Clean shutdown.'))

What does this code do?

  • docker stop gracefully shuts down the container, giving nginx time to close connections.
  • docker rm removes the stopped container from Docker's registry.
  • .quiet() suppresses the container ID that Docker normally prints, keeping your script output clean.
  • Save scripts/docker-up.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it.

ⓧ I'd like to double check the full code

Scroll up and confirm your scripts/docker-up.ts contains these three blocks in order:

  • The container lifecycle block (import, constants, cleanup, and docker run).
  • The health checking block (spinner wrapping retry, plus docker inspect parsing).
  • The teardown block (docker stop and docker rm).

Before you run this, what do you expect to happen? Think about the full lifecycle: start, health check, status, teardown.

  • Run the full orchestration script:
npx zx scripts/docker-up.ts

You should see nginx start, the spinner animate during health checks, the service URL and container status printed, then a clean teardown confirmation at the end.

Health check timing out?

  • Make sure Docker Desktop is running and the container actually started. Run docker ps to see if toolkit-nginx appears in the list.
  • If port 8080 is in use by another process, change HOST_PORT to a different value and try again.
  • If you see a network error from fetch(), check that your firewall isn't blocking localhost connections on that port.

Why is my Docker health check failing?

Your toolkit now handles the complete container lifecycle. You can hand these scripts to a teammate on macOS or Linux and they run without modification. Next up: a secret mission that takes your orchestration to the next level with CLI arguments and multi-container support.

Secret mission

Multi-Container Orchestration with CLI Arguments

Real services rarely run solo. In this challenge, extend your Docker orchestrator to accept CLI flags, spin up multiple containers in parallel, and health-check each one independently.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with open-source tools, so there are no ongoing costs.

Resources you used:

  • Docker containers: toolkit-nginx and toolkit-nginx-redis. These are stopped and removed by the scripts during teardown. They only linger if a script crashed mid-run.
  • Generated output directories: dist/ and dist/processed/ created by the build and process scripts.
  • Project directory: automation-toolkit on your Desktop, containing all scripts, source files, and configuration.

Keep everything running

No action needed. Choose this if you plan to keep developing your automation scripts or want to reference them later.

  • The scripts handle their own container teardown, so no Docker containers are left running after a successful script execution.
  • The dist/ directory takes minimal disk space and is regenerated each time you run the build script.
  • There are no ongoing costs. Everything is local and open-source.

Pause - I'll come back to this later

Clean up any lingering containers in case a script crashed mid-run. Your project files stay intact for when you return.

  • Remove any lingering containers by running this command in your terminal:
docker rm -f toolkit-nginx toolkit-nginx-redis
  • If the containers do not exist, the command prints a short error message and does nothing. That is fine.
  • Your project files in automation-toolkit remain on disk for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the entire project directory and any lingering containers.

  • Remove any lingering Docker containers by running:
docker rm -f toolkit-nginx toolkit-nginx-redis
  • Navigate to your Desktop and delete the project directory.

macOS

  • Run this command to remove the project:
cd ~/Desktop
rm -rf automation-toolkit

Windows

  • Run this command in PowerShell to remove the project:
cd ~/Desktop
Remove-Item -Recurse -Force automation-toolkit
  • Confirm the directory is gone by running ls on your Desktop. You should no longer see automation-toolkit in the listing.

That's a Wrap!

That's a Wrap!

Nice work! You just built a full cross-platform developer automation toolkit powered by zx and TypeScript. These scripts run identically on Windows, macOS, and Linux without a single platform-specific hack.

You've learned how to:

  • Build a cross-platform automation toolkit using zx with usePowerShell() for native Windows execution while keeping scripts portable to any OS.
  • Hit the cross-platform wall where shell commands break across operating systems and solved it with JavaScript-level utilities like glob, fs-extra, and path.
  • Orchestrated Docker containers with retry()-based health checks, spinner() feedback, and clean teardown using Promise.all for parallel execution.
  • Secret Mission: Added CLI argument parsing with argv and multi-container parallel orchestration with independent health checks for nginx and Redis.

Ready to quiz yourself?