Skip to content

Backend Deployment - AWS Tagging Guide

What Tagging Is (and Why It Matters)

Tags are key–value pairs you attach to AWS resources to describe and organize them. They make it possible to:

  • Understand your AWS costs — e.g., cost per project, per environment, or per team.
  • Track deployments — know which code version or commit deployed which instance.
  • Automate management — scripts and tools can find or clean up resources by tags.
  • Secure and govern — enforce that all resources have proper owners and environments.

Tagging turns a chaotic cloud into an accountable, searchable system.


The Tagging Philosophy for This Project

We use a consistent, prefix-based tag schema to keep ownership and automation clear. All custom tags start with mow: to avoid conflicts with AWS system tags.

Core tag keys

Key Example Value Purpose
mow:project mow Identifies the overall project
mow:component backend, ios Component/application area
mow:service django, postgres Sub-service (optional)
mow:environment dev, stage, prod Deployment environment
mow:owner james Human or team responsible
mow:managed-by manual, gh-actions Creation/maintenance mechanism
mow:cost-center ops-aws-ec2, ops-aws-s3 Cost tracking label
mow:repo github.com/your-org/mow-backend Source repository
mow:git-sha a1b2c3d Short commit SHA at deploy time
mow:version backend@1.0.0 Version or container tag
Name mow-backend-django-dev Human-readable name in AWS Console

Example tag set for an EC2 instance

Key Value
Name mow-backend-prod
mow:project mow
mow:environment prod
mow:owner james
mow:managed-by manual
mow:repo github.com/your-org/mow-backend
mow:git-sha a1b2c3d
mow:version backend@1.0.0

Naming Conventions

Tag names are case-sensitive. Use lowercase, hyphens for separation, and prefix custom tags with mow:.

Examples:

  • mow:cost-center
  • mow:environment
  • mow:git-sha

This avoids conflicts with AWS system tags (e.g., aws:cloudformation:stack-name) and simplifies automation.


Local Setup

You’ll need:

  • AWS CLI v2, authenticated via SSO aws sso login --profile admin-cli-sso
  • jq for JSON manipulation

  • macOS: brew install jq

  • Ubuntu/Debian: sudo apt install jq

Tag Files & Layering Model

We store reusable tag fragments under aws/tags/. The layering model lets you compose a final tag set by stacking files in explicit order (similar to Docker Compose):

  • Pass one or more files with -f <file>, in order.
  • Each file is a JSON array of {"Key":"...","Value":"..."}.
  • Later files override earlier files when keys collide (“last write wins”).
  • After files are merged, dynamic overrides are added last via --tag "Key:Value" and optional --name.

Example structure (from this repo):

aws/
  tags/
    backend/
      base.json
      django.json
      stage.json
      prod.json
    ios/
    aos/

Example file contents

aws/tags/backend/base.json

[
  {"Key":"mow:project","Value":"mow"},
  {"Key":"mow:component","Value":"backend"},
  {"Key":"mow:owner","Value":"james"},
  {"Key":"mow:managed-by","Value":"manual"},
  {"Key":"mow:cost-center","Value":"ops-aws-ec2"},
  {"Key":"mow:repo","Value":"github.com/your-org/mow-backend"}
]

aws/tags/backend/django.json

[
  {"Key":"mow:service","Value":"django"}
]

aws/tags/backend/stage.json

[
  {"Key":"mow:environment","Value":"stage"}
]

Compose them in any order you want. Later files win on duplicate keys.


Tagging Script (EC2 by ID)

Script path: aws/scripts/tag-ec2-resources-by-id.sh Purpose: Apply standardized, layered tags to EC2-family resources (instances, volumes, SGs, subnets, route tables, ENIs, etc.) by resource ID.

✅ If --profile is omitted and AWS_PROFILE is set, that profile will be used. ✅ DRY_RUN=1 (default) previews; set DRY_RUN=0 to apply.

How it works

  1. Start with an empty tag list.
  2. For each -f <file> (in order), merge into the list. Later files override earlier by Key (last write wins).
  3. Inject dynamic tags (always last):

  4. mow:git-sha$GIT_SHA (or unknown)

  5. mow:version$APP_VERSION (or unknown)
  6. mow:managed-bycicd if in CI; else manual
  7. mow:tagged-at ← UTC timestamp
  8. Optional Name via --name-tag "<value>"
  9. Apply explicit overrides last via --tag '<JSON object>' (repeatable), where JSON is:

  10. {"Key":"<key>","Value":"<value>"}

  11. Example: --tag '{"Key":"mow:owner","Value":"platform-team"}'
  12. Send the merged tag array to aws ec2 create-tags.

Usage

export GIT_SHA=$(git rev-parse --short HEAD)
export APP_VERSION="backend@1.0.0"

# Dry run (default) – preview exactly what WOULD be applied
./aws/scripts/tag-ec2-resources-by-id.sh \
  --region us-east-1 \
  -f aws/tags/backend/base.json \
  -f aws/tags/backend/django.json \
  -f aws/tags/backend/stage.json \
  --name-tag "mow-django-stage-web-1" \
  --tag '{"Key":"mow:owner","Value":"platform-team"}' \
  --print-keys 1 \
  -- i-0123abc vol-0456def

Output highlights:

  • Announces DRY_RUN=1 and prints the resolved final tag set (pretty JSON).
  • With --print-keys 1, prints a concise, numbered list of keys.
  • Shows a clear “DRY RUN — would execute …” command preview.

To actually apply tags:

DRY_RUN=0 ./aws/scripts/tag-ec2-resources-by-id.sh \
  --region us-east-1 \
  -f aws/tags/backend/base.json \
  -f aws/tags/backend/django.json \
  -f aws/tags/backend/prod.json \
  --name-tag "mow-django-prod-web-1" \
  --tag '{"Key":"mow:owner","Value":"platform-team"}' \
  --print-keys 1 \
  -- i-0abc123 i-0def456

With DRY_RUN=0:

  • After success, if --print-keys 1 is set, it prints “Applied tag keys” for confirmation.

Options

  • -f <file> — JSON array of {"Key","Value"} (repeatable; order matters)
  • --name-tag <NAME> — sets/overrides the Name tag
  • --tag '<JSON object>' — inline single-tag override; repeatable; applied last

  • Must be like {"Key":"...","Value":"..."} (use single quotes in shells)

  • --print-keys 1 — print a concise list of keys (works in dry run and apply)
  • --region <region> — AWS region (required)
  • --profile <name> — AWS CLI profile (optional; defaults to $AWS_PROFILE if set)

Notes

  • Warns if the final tag count exceeds ~50 (AWS per-resource limit).
  • Resource IDs go after -- so long ID lists don’t collide with options.
  • Uses array-based CLI invocation (no eval) and a portable timestamp via date -u -Iseconds.

Using Tags in CI/CD

Typical pipeline snippet (GitHub Actions example):

export GIT_SHA="${GITHUB_SHA::7}"
export APP_VERSION="backend@${GITHUB_RUN_NUMBER}"

# Preview during a plan step:
./aws/scripts/tag-ec2-resources-by-id.sh \
  --region us-east-1 \
  -f aws/tags/backend/base.json \
  -f aws/tags/backend/django.json \
  -f aws/tags/backend/prod.json \
  --name-tag "mow-django-prod-web-${GITHUB_RUN_NUMBER}" \
  --tag '{"Key":"mow:owner","Value":"platform-team"}' \
  --print-keys 1 \
  -- i-0123abc

# Then apply in a deploy step:
DRY_RUN=0 ./aws/scripts/tag-ec2-resources-by-id.sh \
  --region us-east-1 \
  -f aws/tags/backend/base.json \
  -f aws/tags/backend/django.json \
  -f aws/tags/backend/prod.json \
  --name-tag "mow-django-prod-web-${GITHUB_RUN_NUMBER}" \
  --tag '{"Key":"mow:owner","Value":"platform-team"}' \
  --print-keys 1 \
  -- i-0123abc

Benefits

  • Each run documents the tag layers used (-f … -f …).
  • Inline overrides are obvious in logs (--tag …).
  • Keys are printed for auditability when --print-keys 1 is set.

Tagging Script — SSM by name/ID

Script path: aws/scripts/tag-ssm-resources-by-id.sh

Purpose: Apply standardized, layered AWS tags to SSM resources (typically Parameter Store keys) by name/ID using native SSM API calls (add-tags-to-resource).

Use this when you have the parameter name (e.g. /path/to/param) rather than an ARN.

✅ If --profile is omitted and AWS_PROFILE is already set, that profile will be used automatically. ✅ DRY_RUN=1 (default) — previews without applying.


Layering Model (docker-compose style)

  1. Starts from an empty tag array: []
  2. For each -f <file> in order:

  3. Merge JSON array of {Key,Value} objects

  4. Later files override earlier ones (last write wins)
  5. Dynamic tags appended last (override previous):

  6. mow:git-sha$GIT_SHA or "unknown"

  7. mow:version$APP_VERSION or "unknown"
  8. mow:managed-by"cicd" if running in CI; else "manual"
  9. mow:tagged-at ← UTC timestamp
  10. Optional Name via --name-tag "<value>"
  11. Inline overrides via --tag '{"Key":"k","Value":"v"}' (repeatable)
  12. Applies tags to each resource via:

aws ssm add-tags-to-resource ...

Default --resource-type = Parameter For Parameter Store, resource-id = full parameter name (e.g. /mow/backend/...).


Usage

Dry run (default)

This previews the combined tag set and what would be applied — but does not change anything.

export GIT_SHA=$(git rev-parse --short HEAD)
export APP_VERSION="backend@1.0.0"

./aws/scripts/tag-ssm-resources-by-id.sh \
  --region us-east-1 \
  -f aws/tags/backend/base.json \
  -f aws/tags/backend/postgres.json \
  -f aws/tags/backend/stage.json \
  --name-tag "mow-backend-postgres-stage" \
  --print-keys \
  -- \
  /mow/backend/postgres/stage/POSTGRES_DB

Apply for real

DRY_RUN=0 ./aws/scripts/tag-ssm-resources-by-id.sh \
  --region us-east-1 \
  -f aws/tags/backend/base.json \
  -f aws/tags/backend/postgres.json \
  -f aws/tags/backend/prod.json \
  --name-tag "mow-backend-postgres-prod" \
  --print-keys \
  -- \
  /mow/backend/postgres/prod/POSTGRES_DB

Syntax

aws/scripts/tag-ssm-resources-by-id.sh \
  [--profile <aws-profile>] \
  --region <region> \
  -f <tags.json> [-f <more.json> ...] \
  [--name-tag <NAME>] \
  [--tag '{"Key":"k","Value":"v"}' ...] \
  [--resource-type <TYPE>] \
  [--print-keys] \
  -- \
  <resource-id> [more-ids...]

Options

Flag Description
--profile <name> AWS profile (default: $AWS_PROFILE if set)
--region <region> AWS region
-f <file> JSON array of {Key,Value} — repeatable; order matters
--name-tag <value> Add / override Name tag
--tag '{...}' Inline single {Key,Value} override — repeatable; applied last
--resource-type <TYPE> SSM resource type — default Parameter
--print-keys Show tag keys after merge (dry-run → “would apply”)
DRY_RUN=0 Apply changes — default DRY_RUN=1

--tag JSON must look like: {"Key":"my-key","Value":"some-value"} (We recommend quoting with single quotes for shell safety.)


Notes

  • Warns if more than ~50 tags (AWS limit).
  • For Parameter Store, resource-id is the full name (starts with /).
  • Resource name arguments appear after -- to avoid conflict with flags.
  • Multi-resource tagging is supported:

bash -- /name/one /name/two /name/three


Example: Multiple Resources

DRY_RUN=0 ./aws/scripts/tag-ssm-resources-by-id.sh \
  --region us-east-1 \
  -f aws/tags/base.json \
  -f aws/tags/stage.json \
  --name-tag "my-service" \
  -- \
  /my/app/key1 \
  /my/app/key2 \
  /my/app/key3

Tag Resolution Order

Last write wins This supports “base → service → environment → inline” style layering.

Order:

  1. -f base.json
  2. -f service.json
  3. -f env.json
  4. dynamic tags (mow:* + optional Name)
  5. inline --tag overrides

Required Permissions

Your AWS identity must permit:

Action
ssm:AddTagsToResource
ssm:ListTagsForResource
(usually) ssm:GetParameters

When to Use

✅ Backfilling tags after migrating to Parameter Store ✅ Standardizing tags across multiple environments ✅ Tagging multiple keys at once ✅ Enforcing Name + mow:* tags


Tagging Script (by ARN)

Script path: aws/scripts/tag-resources-by-arn.sh Purpose: Apply standardized, layered tags to arbitrary AWS resources by ARN via the Resource Groups Tagging API. Use this for tagging S3, RDS, Lambda, IAM, SSM parameters, EC2-by-ARN, and other resources.

✅ If --profile is omitted and AWS_PROFILE is set, that profile is used. ✅ DRY_RUN=1 (default) previews; set DRY_RUN=0 to apply.


How it works (layering model)

  1. Start from an empty internal tag map {}.
  2. For each -f <file> (in order), normalize and merge into the map. Later files override earlier keys (last write wins).

Each -f file may be:

  • A JSON array of objects {"Key","Value"} (preferred; shares files w/ EC2 + SSM), or
  • A JSON map/object of "Key":"Value" pairs

Both formats are auto-converted into a map internally. 3. Inject dynamic tags (always last):

  • mow:git-sha$GIT_SHA (or unknown)
  • mow:version$APP_VERSION (or unknown)
  • mow:managed-bycicd in CI; else manual
  • mow:tagged-at ← UTC timestamp
  • Optional Name if --name-tag is given
  • Apply inline overrides via --tag '<JSON object>' (repeatable), where JSON is:

{"Key":"<key>","Value":"<value>"}

Inline tags override everything before them (last write wins). 5. Execute:

aws resourcegroupstaggingapi tag-resources \ --resource-arn-list ... \ --tags <merged-map>


Usage

Dry-run (default — previews add):

export GIT_SHA=$(git rev-parse --short HEAD)
export APP_VERSION="backend@1.0.0"

./aws/scripts/tag-resources-by-arn.sh \
  --region us-east-1 \
  -f aws/tags/backend/base.json \
  -f aws/tags/backend/django.json \
  -f aws/tags/backend/stage.json \
  --name-tag "mow-django-stage-web-1" \
  --tag '{"Key":"mow:owner","Value":"platform-team"}' \
  --print-keys 1 \
  -- arn:aws:s3:::my-bucket \
     arn:aws:rds:us-east-1:123456789012:db:mydb

Apply for real:

DRY_RUN=0 ./aws/scripts/tag-resources-by-arn.sh \
  --region us-east-1 \
  -f aws/tags/backend/base.json \
  -f aws/tags/backend/django.json \
  -f aws/tags/backend/prod.json \
  --name-tag "mow-django-prod-web-1" \
  --tag '{"Key":"mow:owner","Value":"platform-team"}' \
  --print-keys 1 \
  -- arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123

Output highlights

  • Prints a merged final tag map (pretty JSON).
  • If --print-keys 1 is specified:

  • With DRY_RUN: prints “DRY RUN — would apply…” keys

  • With apply: prints “Applied tag keys”

Options

Flag Description
-f <file> JSON array of {Key,Value} or JSON map; repeatable; order matters
--name-tag <NAME> Sets the Name tag
--tag '<JSON>' Inline single-tag override; repeatable; applied last
--print-keys 1 Print concise key list
--region <region> AWS region (required)
--profile <profile> AWS CLI profile (optional; defaults to $AWS_PROFILE)

--tag objects must look like: {"Key":"...","Value":"..."} (Use single quotes to protect JSON from the shell.)


Notes

  • Automatically normalizes tag files so you can reuse the same JSON array files everywhere.
  • Warns if final tag count > ~50 (AWS per-resource limit).
  • ARNs go after --, allowing long lists without clashing with options.
  • Uses array-based CLI invocation and UTC timestamp (date -u -Iseconds).

Integrating Tags with Billing

1) Activate Tags as Cost Allocation Tags

  1. Open Billing Console → Cost Allocation Tags.
  2. Activate the desired tags (mow:project, mow:cost-center, etc.).
  3. AWS will begin attributing costs to these tags going forward. (Propagation to Cost Explorer can take up to ~24 hours.)

2) Slice Costs in Cost Explorer

  • Open Cost Explorer and Group by → Tag.
  • Use views like:

  • “Cost by project” (mow:project)

  • “Cost by cost-center” (mow:cost-center)
  • “Cost by environment” (mow:environment)
  • “Cost by owner” (mow:owner)

Project-Level Tagging Best Practices

  1. Minimum set on every resource: mow:project, mow:component, mow:environment, mow:owner, mow:managed-by, mow:cost-center, and Name.
  2. Tag early. Bake tags into IaC, launch templates, or CI/CD steps.
  3. Keep names consistent. Lowercase, hyphen-separated, mow: prefix.
  4. Don’t forget infra. Tag VPCs, subnets, gateways, security groups.
  5. Regular reviews. Use inventory/cleanup scripts to catch and fix gaps.

Summary

  • The layering model (-f file -f file …) makes every tagging decision explicit and reproducible.
  • Dynamic overrides via --tag "Key:Value" (and --name) are applied last.
  • --print-keys plus the DRY_RUN default aligns logs across local and CI, making changes easy to audit.
  • Activating cost allocation tags unlocks clean project-level cost visibility.
  • Over time, you can add enforcement (SCP/IAM/Config) as needed.


< Backend Deployment - AWS CLI Install Setup macOS

Next: Backend Deployment - AWS SSM Parameter Store >