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-centermow:environmentmow: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
--profileis omitted andAWS_PROFILEis set, that profile will be used. ✅DRY_RUN=1(default) previews; setDRY_RUN=0to apply.
How it works¶
- Start with an empty tag list.
- For each
-f <file>(in order), merge into the list. Later files override earlier byKey(last write wins). -
Inject dynamic tags (always last):
-
mow:git-sha←$GIT_SHA(orunknown) mow:version←$APP_VERSION(orunknown)mow:managed-by←cicdif in CI; elsemanualmow:tagged-at← UTC timestamp- Optional
Namevia--name-tag "<value>" -
Apply explicit overrides last via
--tag '<JSON object>'(repeatable), where JSON is: -
{"Key":"<key>","Value":"<value>"} - Example:
--tag '{"Key":"mow:owner","Value":"platform-team"}' - 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 1is set, it prints “Applied tag keys” for confirmation.
Options¶
-f <file>— JSON array of{"Key","Value"}(repeatable; order matters)--name-tag <NAME>— sets/overrides theNametag-
--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_PROFILEif 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 viadate -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 1is 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
--profileis omitted andAWS_PROFILEis already set, that profile will be used automatically. ✅DRY_RUN=1(default) — previews without applying.
Layering Model (docker-compose style)¶
- Starts from an empty tag array:
[] -
For each
-f <file>in order: -
Merge JSON array of
{Key,Value}objects - Later files override earlier ones (last write wins)
-
Dynamic tags appended last (override previous):
-
mow:git-sha←$GIT_SHAor"unknown" mow:version←$APP_VERSIONor"unknown"mow:managed-by←"cicd"if running in CI; else"manual"mow:tagged-at← UTC timestamp- Optional
Namevia--name-tag "<value>" - Inline overrides via
--tag '{"Key":"k","Value":"v"}'(repeatable) - Applies tags to each resource via:
aws ssm add-tags-to-resource ...
Default
--resource-type=ParameterFor 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 |
--tagJSON 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-idis 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:
-f base.json-f service.json-f env.json- dynamic tags (
mow:*+ optionalName) - inline
--tagoverrides
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
--profileis omitted andAWS_PROFILEis set, that profile is used. ✅DRY_RUN=1(default) previews; setDRY_RUN=0to apply.
How it works (layering model)¶
- Start from an empty internal tag map
{}. - 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(orunknown)mow:version←$APP_VERSION(orunknown)mow:managed-by←cicdin CI; elsemanualmow:tagged-at← UTC timestamp- Optional
Nameif--name-tagis 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 1is 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) |
--tagobjects 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¶
- Open Billing Console → Cost Allocation Tags.
- Activate the desired tags (
mow:project,mow:cost-center, etc.). - 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¶
- Minimum set on every resource:
mow:project,mow:component,mow:environment,mow:owner,mow:managed-by,mow:cost-center, andName. - Tag early. Bake tags into IaC, launch templates, or CI/CD steps.
- Keep names consistent.
Lowercase, hyphen-separated,
mow:prefix. - Don’t forget infra. Tag VPCs, subnets, gateways, security groups.
- 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-keysplus 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.