Exports, Imports, and Modules in Modern TypeScript/JavaScript

Exports, Imports, and Modules in Modern TypeScript/JavaScript

Things start getting confusing when your npm package wants to support both ECMAScript Modules (ESM) and CommonJS (CJS), which makes it a dual-package.

Assume we have a package whose file structure looks like this:

./
├── src/
│   ├── shared/
│   │   └── index.ts
│   ├── node/
│   │   ├── cli.ts
│   │   ├── index.cts
│   │   └── index.ts
│   └── browser/
│       ├── pangu.ts
│       └── pangu.umd.ts
├── dist/
│   ├── shared/
│   │   └── index.js
│   ├── node/
│   │   ├── cli.js
│   │   ├── index.cjs
│   │   └── index.js
│   └── browser/
│       ├── pangu.js
│       └── pangu.umd.js
└── package.json

Entry Points

Let's start from package.json. Here is a simplified one from pangu.js:

{
  "name": "pangu",
  ...
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/node/index.d.ts",
        "default": "./dist/node/index.js"
      },
      "require": {
        "types": "./dist/node/index.d.cts",
        "default": "./dist/node/index.cjs"
      }
    }
  },
  ...
}

Simply speaking, the entry points of a package are (usually) defined in package.json's exports field:

  • import xxx from "pangu" matches the "import" branch, which loads ./dist/node/index.js => ESM
  • require("pangu") matches the "require" branch, which loads ./dist/node/index.cjs => CommonJS

Condition Matching Order

In the above package.json, you may have noticed that every branch puts "types" first. That's not a style choice.

Within the exports object, key order is significant. During condition matching, the resolver (Node.js, TypeScript, or any bundler) walks the object from top to bottom, takes the first key it recognizes, then stops. Everything below the winner is ignored.

  • "types" must go first, so that TypeScript always sees your .d.ts
  • "default" must go last. Anything you list after it is unreachable

ref:
https://nodejs.org/api/packages.html#conditional-exports
https://www.typescriptlang.org/docs/handbook/modules/reference.html#packagejson-exports

ECMAScript Modules (ESM)

ESM is the current standard module system in the JavaScript specification. You should really consider using it if you're not.

A piece of code is an ECMAScript module if any of the following is true:

  • A file ends with .mjs or .mts
  • A file ends with .js or .ts AND package.json has "type": "module"
    • When tsconfig.json sets "module": "esnext" or "moduleResolution": "bundler", every .ts file is treated as ESM no matter what "type" says
  • Inside a <script type="module"> tag
// export
export class NodePangu {}
export const pangu = new NodePangu();
export default pangu;

// import from a file (inside the same package)
import { Pangu } from '../shared/index.js'; // relative imports need the extension in ESM, and you write .js even though the source file is index.ts

// import from a package
import pangu from 'pangu'; // it loads ./dist/node/index.js according to package.json["exports"]["."]["import"]["default"]
import { pangu, NodePangu } from 'pangu';

The mapping between export and import:

  • export default pangu; => import pangu from 'pangu';
  • export const pangu = new NodePangu(); => import { pangu } from 'pangu';
  • export class NodePangu {} => import { NodePangu } from 'pangu';

ref:
https://www.typescriptlang.org/docs/handbook/2/modules.html
https://nodejs.org/api/esm.html

Subpath Exports

You just define an extra key in exports, ./browser in our case:

{
  "name": "pangu",
  ...
  "type": "module",
  "exports": {
    ".": {
      ...
    },
    "./browser": {
      "import": {
        "types": "./dist/browser/pangu.d.ts",
        "default": "./dist/browser/pangu.js"
      }
    }
  },
  ...
}

The export part is the same, but you will need to add the subpath (/browser) when importing.

// export
export class BrowserPangu {}
export const pangu = new BrowserPangu();
export default pangu;

// import from a package
import pangu from 'pangu/browser'; // it loads ./dist/browser/pangu.js according to package.json["exports"]["./browser"]["import"]["default"]
import { pangu, BrowserPangu } from 'pangu/browser';

It's worth noting that in ESM, only relative imports must include the file extension (.js, .cjs, or .mjs), because they are resolved as plain URLs with no extension guessing. Package names like pangu and pangu/browser don't need one, since they are resolved through the exports field instead of the filesystem.

CommonJS

CommonJS is an old but still widely used module system, especially in the Node.js ecosystem.

A piece of code is a CommonJS module if any of the following is true:

  • A file ends with .cjs or .cts
  • A file ends with .js or .ts AND package.json has "type": "commonjs" (or has no "type" field)

A plain <script> tag is not on the list. It's a classic script, not a CommonJS module. Browsers have never supported CommonJS natively, so require() only works on the web after a bundler rewrites it.

class NodePangu {}
const pangu = new NodePangu();

// export
module.exports = pangu;
module.exports.NodePangu = NodePangu;

// import from a file (inside the same package)
const { Pangu } = require('../shared/index'); // the extension is optional in CommonJS

// import from a package
const pangu = require('pangu'); // it loads ./dist/node/index.cjs according to package.json["exports"]["."]["require"]["default"]
const { NodePangu } = require('pangu');

The mapping between exports and require:

  • module.exports = pangu; => const pangu = require('pangu');
  • module.exports.NodePangu = NodePangu; => const { NodePangu } = require('pangu');

There is also a variable named exports in CommonJS, which is basically an alias of module.exports. Assigning to exports only rebinds that local variable, and what actually gets exported is still whatever module.exports points to.

// You can think of it like this in every CommonJS module
var exports = module.exports = {};

The rule of thumb: just use module.exports, never use exports.

ref:
https://nodejs.org/api/modules.html
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules

Expose a Local Service with Cloudflare Tunnel

Expose a Local Service with Cloudflare Tunnel

Expose a service running on your local machine to a remote server without opening any ports. For instance, let your OpenClaw agent (the remote server) access qBittorrent Web UI on your Mac (the local machine), to download a movie for you.

The local machine makes an outbound-only connection to Cloudflare. The remote server hits your subdomain on Cloudflare's edge. Traffic flows:

OpenClaw on your remote server -> https://your-tunnel-name.example.com -> Cloudflare edge servers -> Cloudflare Tunnel -> qBittorrent Web UI on your local machine

You can probably do the same thing with Tailscale, but unfortunately, Tailscale app doesn't work well with Mullvad VPN on macOS (and I don't want to use Tailscale's Mullvad VPN add-on).

ref:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
https://tailscale.com/docs/features/exit-nodes/mullvad-exit-nodes

Setup

1. Create Cloudflare Tunnel

Do this from any device where you're logged into Cloudflare. No login needed on the local machine or the remote server.

  1. Go to Cloudflare Zero Trust dashboard
  2. Networks -> Connectors -> Create a tunnel -> Cloudflared
    • Name your tunnel: your-tunnel-name
  3. Copy the tunnel token
  4. Configure the tunnel you just created -> Published application routes -> Add a published application route
    • Subdomain: your-tunnel-name
    • Domain: select your domain from the dropdown (e.g., example.com)
    • Path: [leave empty]
    • Service:
      • Type: HTTP
      • URL: localhost:8080
  5. After you create the published application route, Cloudflare will automatically create the DNS record for your subdomain

ref:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/tunnel-useful-terms/
https://developers.cloudflare.com/cloudflare-one/networks/routes/add-routes/

2. Access Controls for Cloudflare Tunnel

Still in the Cloudflare Zero Trust dashboard.

  1. Access controls -> Service credentials -> Service Tokens -> Create Service Token
    • Token name: your-token-name
    • Service Token Duration: Non-expiring
    • Save the CF-Access-Client-Id and CF-Access-Client-Secret (shown only once)
  2. Access controls -> Policies -> Add a policy
    • Policy name: your-policy-name
    • Action: Service Auth
    • Session duration: 24 hours
    • Configure rules -> Include:
      • Selector: Service Token
      • Value: select the service token you just created (e.g., your-token-name)
  3. Access controls -> Applications -> Add an application -> Self-hosted
    • Application name: your-tunnel-name
    • Session Duration: 24 hours
    • Add public hostname:
      • Input method: Default
      • Subdomain: your-tunnel-name (must match the subdomain in step 1.4)
      • Domain: select your domain from the dropdown (e.g., example.com)
      • Path: [leave empty]
    • Select existing policies (this text is a clickable button, not a label!)
      • Check the policy you created in step 2.2

ref:
https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/
https://developers.cloudflare.com/cloudflare-one/access-controls/policies/
https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/

3. Run cloudflared on Local Machine (macOS)

Make cloudflared run on boot, connecting outbound to Cloudflare. No browser auth ever needed.

brew install cloudflared

# install as a LaunchAgent using the tunnel token from step 1
sudo cloudflared service install YOUR_TUNNEL_TOKEN

ref:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/

To verify it's running:

sudo launchctl list | grep cloudflared

4. Access the Local Service on Remote Server

Test that the tunnel and access policy work. We're accessing qBittorrent Web UI here:

curl \
  -H "CF-Access-Client-Id: $YOUR_CF_ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $YOUR_CF_ACCESS_CLIENT_SECRET" \
  -d "username=YOUR_USERNAME&password=YOUR_PASSWORD" \
  https://your-tunnel-name.example.com/api/v2/auth/login

The CF-Access-XXX headers must be included on every request. Without them, Cloudflare returns a 302 redirect to a login page.

ref:
https://github.com/qbittorrent/qBittorrent/wiki/#webui

Why Cloudflare Tunnel Over Tailscale

  • No login on endpoints: The tunnel token is scoped to one tunnel, can't access your Cloudflare account
  • No VPN conflicts: cloudflared is just outbound HTTPS, Mullvad VPN doesn't care
  • Free: Cloudflare Zero Trust free tier covers this
Claude Code: Things I Learned After Using It Every Day

Claude Code: Things I Learned After Using It Every Day

I've used Claude Code daily since it came out. Here are the best practices, tools, and configuration patterns I've picked up. Most of this applies to other coding agents (Codex) too.

TL;DR
My configs, plugins, and skills for Claude Code:
https://github.com/vinta/hal-9000

CLAUDE.md

The Global CLAUDE.md

Your ~/.claude/CLAUDE.md should only contain:

  • Your preferences and nudges to correct agent behaviors
  • You probably don't need to tell it YAGNI or KISS. They're already built in

Pro tip 1: before adding something to CLAUDE.md, ask it, "Is this already covered in your system prompt?"
Pro tip 2: try my refactor-claude-md skill!

Here are some parts of my CLAUDE.md I found useful:

<use_ask_user_question>
When you need input and the answer is a selection rather than a sentence (multiple-choice, yes/no confirmations that gate next steps, picking from a list, choosing between approaches), ask with the AskUserQuestion tool instead of plain text, so the user clicks an option instead of typing.

When presenting approaches, put the summary in each option's label and the pros/cons in its description. Plain text is fine when the answer is open-ended. This changes the format of questions, not whether to ask: never use it to ask permission for work you already have enough information to do.
</use_ask_user_question>

<prefer_online_sources>
Training data goes stale: library/framework/SDK APIs, config keys, CLI flags, cloud services, platform features, syntax, and versions change, and guessing has repeatedly cost debugging round-trips.

Invoke the find-docs skill BEFORE writing code or config that touches any of those, and BEFORE answering questions about them. Being about to write such code is trigger enough, even when no question was asked. Confidence is not an exemption, and neither is the library being well known. Answering from training data, or fetching a remembered docs URL instead of invoking the skill, does not satisfy this rule.

If the user provides URLs, WebFetch each one as a primary source before searching further. Never skip user-provided URLs. For topics find-docs covers poorly, WebFetch the official docs instead of falling back to training data.
</prefer_online_sources>

<auto_commit if="you have completed the user's requested change">
Use the commit skill to commit, always passing a brief description of what changed (e.g. /commit add login endpoint). Don't batch unrelated changes into one commit.
</auto_commit>

Also see:

The Project CLAUDE.md

For project-specific instructions, put them in the project-level CLAUDE.md.

The highest-signal content in your project CLAUDE.md (or any skill) is the Gotchas section. Build these from the failure points Claude Code actually runs into.

Also see:

Per File Type Rules

For language-specific or per-file rules, put them in ~/.claude/rules/, so Claude Code only loads them when editing those file types.

For instance, ~/.claude/rules/typescript-javascript.md:

---
paths:
  - "**/*.{ts,tsx}"
  - "**/*.{js,jsx}"
  - "**/package.json"
---

# TypeScript / JavaScript

- Pin exact dependency versions in package.json — no ^ or ~ prefixes
- Use node: prefix for Node.js built-in modules (e.g., node:fs, node:path)
- Write proper types/interfaces instead of any or casts like as any / as unknown. When a value is genuinely untypable, use unknown and narrow it explicitly. any is the last resort when no typed alternative exists
- Prefer interface over type for object shapes (extendable, better error messages)
- Avoid enums. Use union types (type Status = 'active' | 'inactive') or as const objects
- Mark properties and parameters readonly when they should not be mutated
- Do not add explicit return types. Let TypeScript infer them
- Use the typescript LSP tool for type-aware code navigation when grep's text matching would be ambiguous

The full rules I have:

Configurations

Settings

There are some useful configurations you could set in your ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
    "CLAUDE_CODE_EFFORT_LEVEL": "max",
    "DISABLE_ERROR_REPORTING": "1",
    "DISABLE_EXTRA_USAGE_COMMAND": "1",
    "DISABLE_FEEDBACK_COMMAND": "1"
  },
  "permissions": {
    "allow": ["..."],
    "deny": ["..."],
    "ask": ["..."],
    "defaultMode": "auto"
  },
  "cleanupPeriodDays": 365,
  "model": "opus[1m]",
  "advisorModel": "fable",
  "enableWorkflows": true,
  "showClearContextOnPlanAccept": true,
  "skipAutoPermissionPrompt": true,
  "teammateMode": "tmux",
  "voiceEnabled": true
}

Highlights:

  • "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1": Enable Agent Team feature, a fancy way to consume a huge amount of tokens
  • "permissions.defaultMode": "auto": We use this to pretend it's safer than --dangerously-skip-permissions
  • "cleanupPeriodDays": 365: By default, your chat history (location: ~/.claude/projects/) will be deleted after 30 days
  • "voiceEnabled": true: Enable Voice Dictation feature. Code like a boss!

The full settings I use:

Permissions

If you're not using a sandbox or devcontainer for Claude Code, you may want to block some evil commands in your ~/.claude/settings.json:

{
  "permissions": {
    "deny": [
      "Read(~/.aws/**)",
      "Read(~/.config/**)",
      "Read(~/.docker/**)",
      "Read(~/.dropbox/**)",
      "Read(~/.gnupg/**)",
      "Read(~/.gsutil/**)",
      "Read(~/.kube/**)",
      "Read(~/.npmrc)",
      "Read(~/.orbstack/**)",
      "Read(~/.pypirc)",
      "Read(~/.ssh/**)",
      "Read(~/*_history)",
      "Read(~/**/*credential*)",
      "Read(~/Library/**)",
      "Edit(~/Library/**)",
      "Read(~/Dropbox/**)",
      "Edit(~/Dropbox/**)",
      "Read(//etc/**)",
      "Edit(//etc/**)",
      "Bash(su *)",
      "Bash(sudo *)",
      "Bash(passwd *)",
      "Bash(env *)",
      "Bash(printenv *)",
      "Bash(history *)",
      "Bash(fc *)",
      "Bash(eval *)",
      "Bash(exec *)",
      "Bash(rsync *)",
      "Bash(sftp *)",
      "Bash(telnet *)",
      "Bash(socat *)",
      "Bash(nc *)",
      "Bash(ncat *)",
      "Bash(netcat *)",
      "Bash(nmap *)",
      "Bash(kill *)",
      "Bash(killall *)",
      "Bash(pkill *)",
      "Bash(chmod *)",
      "Bash(chown *)",
      "Bash(chflags *)",
      "Bash(xattr *)",
      "Bash(diskutil *)",
      "Bash(mkfs *)",
      "Bash(security *)",
      "Bash(defaults *)",
      "Bash(launchctl *)",
      "Bash(osascript *)",
      "Bash(dscl *)",
      "Bash(networksetup *)",
      "Bash(scutil *)",
      "Bash(systemsetup *)",
      "Bash(pmset *)",
      "Bash(crontab *)"
    ],
    "ask": [
      "Bash(curl *)",
      "Bash(wget *)",
      "Bash(open *)",
      "Bash(* install *)",
      "Bash(bun add *)",
      "Bash(yarn add *)",
      "Bash(pnpm add *)",
      "Bash(uv add *)",
      "Bash(git push *)",
      "Bash(npx supabase db *)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/hooks/guard-bash-paths.py"
          }
        ]
      }
    ]
  }
}

However, "deny": ["Read(~/.aws/**)", "Read(~/.kube/**)", ...] alone is not enough, since Claude Code can still read sensitive files through the Bash tool. You can write a simple hook to intercept Bash commands that access blocked files, like this guard-bash-paths.py hook.

Though, Claude Code can still write a one-time script to read sensitive data and bypass all of the above defenses. So the safest approach is using sandbox after all.

Plugins

Claude Code Plugins are simply a way to package skills, commands, agents, hooks, and MCP servers. Distributing them as a plugin has the following advantages:

  • Auto update (versioned releases)
  • Auto hooks configuration (users don't need to edit their ~/.claude/settings.json manually)
  • Skills have a /plugin-name:your-skill-name prefix (no more conflicts)

To install a plugin, you need to add a marketplace first. A marketplace is usually just a GitHub repo. Think of it as a namespace.

/plugin marketplace add anthropics/skills
/plugin marketplace add openai/codex-plugin-cc
/plugin marketplace add mattpocock/skills
/plugin marketplace add vinta/hal-9000

# browse plugins
/plugin

Recommended:

Skills

Skills can contain executable scripts and hooks, not just Markdown. Use with caution! When in doubt, have your agent review them first.

Here are skills I use, mostly installed per project when needed:

# my skills
npx skills add https://github.com/vinta/hal-9000 --skill commit blindspot best-practices simple-english refactor-agents-md -g
npx skills add https://github.com/vinta/dear-ai

# workflow skills
npx skills add https://github.com/mattpocock/skills -g

# writing skills
npx skills add https://github.com/softaworks/agent-toolkit --skill writing-clearly-and-concisely humanizer naming-analyzer
npx skills add https://github.com/hardikpandya/stop-slop
npx skills add https://github.com/shyuan/writing-humanizer

# doc skills
npx skills add https://github.com/upstash/context7 --skill find-docs -g

# LLM API skills
npx skills add https://github.com/openai/skills
npx skills add https://github.com/google-gemini/gemini-skills

# backend skills
npx skills add https://github.com/trailofbits/skills --skill modern-python
npx skills add https://github.com/trailofbits/skills-curated --skill python-code-simplifier
npx skills add https://github.com/vintasoftware/django-ai-plugins
npx skills add https://github.com/google/skills
npx skills add https://github.com/cloudflare/skills
npx skills add https://github.com/supabase/agent-skills
npx skills add https://github.com/planetscale/database-skills

# frontend skills
npx skills add https://github.com/vercel-labs/agent-skills
npx skills add https://github.com/vercel-labs/next-skills

# design skills
npx skills add https://github.com/openai/skills --skill frontend-skill
npx skills add https://github.com/pbakaus/impeccable
npx skills add https://github.com/nextlevelbuilder/ui-ux-pro-max-skill
npx skills add https://github.com/Leonxlnx/taste-skill

# seo/aeo skills
npx skills add https://github.com/warpdotdev/oz-skills --skill seo-aeo-audit

# video skills
npx skills add https://github.com/remotion-dev/skills

# browser skills
npx skills add https://github.com/microsoft/playwright-cli --skill playwright-cli -g

npx skills list -g
npx skills update -g
npx skills remove --all -g

Recommended:

  • /brainstorming from superpowers: When in doubt, start with this skill
  • /wayfinder from mattpocock: Let AI ask you a lot of questions
  • /find-docs from context7: Find the latest documentations
  • /frontend-design from impeccable: The better version of the official /frontend-design skill
  • /simplify: Run it often, you will like it
  • /insights: Analyze your Claude Code sessions

You can find more skills on skills.sh.

MCP Servers

You probably don't need any MCP servers if you can do the same thing with CLI + skills.

Context7 MCP

No, just use the ctx7 CLI with find-docs skill instead.

npx ctx7 setup

Playwright MCP

No, you should use the playwright-cli skill instead. Both tools support headed mode (the opposite of headless), if you'd like to see the browser.

npm install -g @playwright/cli
npx skills add https://github.com/microsoft/playwright-cli

GitHub MCP

No, you should use the gh command instead.

brew install gh

Codex MCP

Yes, ironically. Other coding agents like Claude Code can use Codex via MCP, which is slightly more stable than directly invoking it with codex exec via CLI.

# Codex reads your local .codex/config.toml by default
claude mcp add codex --scope user -- codex mcp-server

However, since OpenAI releases the official Claude Code plugin: codex-plugin-cc, you should probably use that instead.

Some Other Tips

Prompt Best Practices

Command Aliases

# in ~/.zshrc
alias cc="claude"
alias ccc="claude --continue"
alias cct='tmux -CC new-session -s "claude-$(date +%s)" claude --teammate-mode tmux'
alias ccy="claude --dangerously-skip-permissions"
ccp() { claude --no-chrome --no-session-persistence -p "$*"; }

Use ccp for ad-hoc prompts:

ccp "commit"
ccp "list all .md in this repo"

Customize Your Statusline

Claude Code has a customizable statusline at the bottom of the terminal. You can run any script that outputs text.

Mine shows the current model, the current working folder, the git branch, and a grammar-corrected version of my last prompt (because my English needs all the help it can get). The grammar correction runs an ad-hoc claude command inside the statusline script.

Claude Code Statusline with English Grammar Check example

Run Ad-Hoc Claude Commands Inside Scripts

You can invoke claude as a one-shot CLI tool from hooks, statusline scripts, CI, or anywhere else. The trick is using the right flags to get a clean, isolated call with zero side effects:

cmd = """
    claude
    --model haiku
    --max-turns 1
    --setting-sources ""
    --tools ""
    --disable-slash-commands
    --no-session-persistence
    --no-chrome
    --print
"""

result = subprocess.run(
    [*shlex.split(cmd), your_prompt],
    capture_output=True,
    text=True,
    timeout=15,
    cwd="/tmp",
)

What each flag does:

  • --setting-sources "": don't load hooks (avoids infinite recursion if called from a hook)
  • --no-session-persistence and cwd="/tmp": avoid polluting your current context
  • --tools "": no file access, no bash, pure text in/out
  • --no-chrome: skip the Chrome integration
Cloudflare Quick Tunnel (TryCloudflare)

Cloudflare Quick Tunnel (TryCloudflare)

Expose your local server to the Internet with one cloudflared command (just like ngrok). No account registration needed, no installation required (via docker run), and free.

# assume your local server is at http://localhost:3000
docker run --rm -it cloudflare/cloudflared tunnel --url http://localhost:3000

# if your local server is running inside a Docker container
docker run --rm -it cloudflare/cloudflared tunnel --url http://host.docker.internal:3000

ref:
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/

You will see something like this in console:

+--------------------------------------------------------------------------------------------+
|  Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):  |
|  https://YOUR_RANDOM_QUICK_TUNNEL_NAME.trycloudflare.com                                   |
+--------------------------------------------------------------------------------------------+

Then you're all set.

GKE Autopilot Cluster: Pay for Pods, Not Nodes

GKE Autopilot Cluster: Pay for Pods, Not Nodes

If you're already on Google Cloud, it's highly recommended to use GKE Autopilot Cluster: you only pay for resources requested by your pods (system pods and unused resources are free in Autopilot Cluster). No need to pay for surplus node pools anymore! Plus the entire cluster applies Google's best practices by default.

ref:
https://cloud.google.com/kubernetes-engine/pricing#compute

Create an Autopilot Cluster

DO NOT enable Private Nodes, otherwise you MUST pay for a Cloud NAT Gateway (~$32/month) for them to access the internet (to pull images, etc.).

# create
gcloud container clusters create-auto my-auto-cluster 
--project YOUR_PROJECT_ID 
--region us-west1

# connect
gcloud container clusters get-credentials my-auto-cluster 
--project YOUR_PROJECT_ID 
--region us-west1

You can update some configurations later on Google Cloud Console.

ref:
https://docs.cloud.google.com/sdk/gcloud/reference/container/clusters/create-auto

Autopilot mode works in both Autopilot and Standard clusters. You don't necessarily need to create a new Autopilot cluster; you can simply deploy your pods in Autopilot mode as long as your Standard cluster meets the requirements:

gcloud container clusters check-autopilot-compatibility my-cluster 
--project YOUR_PROJECT_ID 
--region us-west1

ref:
https://cloud.google.com/kubernetes-engine/docs/concepts/about-autopilot-mode-standard-clusters
https://cloud.google.com/kubernetes-engine/docs/how-to/autopilot-classes-standard-clusters

Deploy Workloads in Autopilot Mode

The only thing you need to do is add one magical config: nodeSelector: cloud.google.com/compute-class: "autopilot". That's it. You don't need to create or manage any node pools beforehand, just write some YAMLs and kubectl apply. All workloads with cloud.google.com/compute-class: "autopilot" will run in Autopilot mode.

More importantly, you are only billed for the CPU/memory resources your pods request, not for nodes that may have unused capacity or system pods (those running under the kube-system namespace). Autopilot mode is both cost-efficient and developer-friendly.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      nodeSelector:
        cloud.google.com/compute-class: "autopilot"
      containers:
        - name: nginx
          image: nginx:1.29.3
          ports:
            - name: http
              containerPort: 80
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi

If your workloads are fault-tolerant (stateless), you can use Spot instances to save a significant amount of money. Just change the nodeSelector to autopilot-spot:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  template:
    spec:
      nodeSelector:
        cloud.google.com/compute-class: "autopilot-spot"
      terminationGracePeriodSeconds: 25 # spot instances have a 25s warning before preemption

ref:
https://docs.cloud.google.com/kubernetes-engine/docs/how-to/autopilot-classes-standard-clusters

You will see something like this in your Autopilot cluster:

kubectl get nodes
NAME                             STATUS   ROLES    AGE     VERSION
gk3-my-auto-cluster-nap-xxx      Ready    <none>   2d18h   v1.33.5-gke.1201000
gk3-my-auto-cluster-nap-xxx      Ready    <none>   1d13h   v1.33.5-gke.1201000
gk3-my-auto-cluster-pool-1-xxx   Ready    <none>   86m     v1.33.5-gke.1201000

The nap nodes are auto-provisioned by Autopilot for your workloads, while pool-1 is a default node pool created during cluster creation. System pods may run on either, but in Autopilot cluster, you are never billed for the nodes themselves (neither nap nor pool-1), nor for the system pods. You only pay for the resources requested by your application pods.

FYI, the minimum resources for Autopilot workloads are:

  • CPU: 50m
  • Memory: 52Mi

Additionally, Autopilot applies the following default resource requests if not specified:

  • Containers in DaemonSets
    • CPU: 50m
    • Memory: 100Mi
    • Ephemeral storage: 100Mi
  • All other containers
    • Ephemeral storage: 1Gi

ref:
https://docs.cloud.google.com/kubernetes-engine/docs/concepts/autopilot-resource-requests

Exclude Prometheus Metrics

You may see Prometheus Samples Ingested in your billing. If you don't need (or don't care about) Prometheus metrics for observability, you could exclude them:

  • Go to Google Cloud Console -> Monitoring -> Metrics Management -> Excluded Metrics -> Metrics Exclusion
  • If you want to exclude all:
    • prometheus.googleapis.com/.*
  • If you only want to exclude some:
    • prometheus.googleapis.com/container_.*
    • prometheus.googleapis.com/kubelet_.*

It's worth noting that excluding Prometheus metrics won't affect your HorizontalPodAutoscaler (HPA) which is using Metrics Server instead.

ref:
https://console.cloud.google.com/monitoring/metrics-management/excluded-metrics
https://docs.cloud.google.com/stackdriver/docs/managed-prometheus/cost-controls