GitHub Actions has become the de facto CI/CD platform for many teams, but most only scratch the surface with pre-built actions from the marketplace. Building custom actions tailored to your infrastructure needs can dramatically reduce boilerplate and enforce consistency across repositories.

Why Custom Actions?

Every DevOps team has workflows that repeat across projects:

  • Deploying to specific cloud environments
  • Running security scans with custom policies
  • Provisioning temporary environments for PR reviews
  • Rotating secrets on a schedule

Instead of copy-pasting YAML across repositories, custom actions encapsulate this logic once and reference it everywhere.

Anatomy of a Composite Action

The simplest custom action type is a composite action — it’s just a YAML file that bundles multiple steps. No Docker or JavaScript required.

Create a repository called infra-actions with this structure:

infratnr-eooarttcriataafataifcycecoot-t-tnrisisismoloeo/-nancna.c.r.pykyeypm/mtmlllsly//

Here’s a practical terraform-apply action:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# terraform-apply/action.yml
name: 'Terraform Apply'
description: 'Initialize and apply Terraform with standard backend config'
inputs:
  working-directory:
    description: 'Directory containing Terraform files'
    required: true
  environment:
    description: 'Target environment (dev/staging/prod)'
    required: true
  aws-role-arn:
    description: 'AWS IAM role to assume'
    required: true

runs:
  using: 'composite'
  steps:
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        role-to-assume: ${{ inputs.aws-role-arn }}
        aws-region: us-east-1

    - name: Setup Terraform
      uses: hashicorp/setup-terraform@v3
      with:
        terraform_version: 1.7.0

    - name: Terraform Init
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      run: |
        terraform init \
          -backend-config="bucket=mycompany-terraform-state" \
          -backend-config="key=${{ inputs.environment }}/terraform.tfstate" \
          -backend-config="region=us-east-1"

    - name: Terraform Apply
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      run: terraform apply -auto-approve -var="environment=${{ inputs.environment }}"

Using Your Custom Action

Reference it from any repository:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# .github/workflows/deploy.yml
name: Deploy Infrastructure

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      
      - name: Deploy to Production
        uses: myorg/infra-actions/terraform-apply@v1
        with:
          working-directory: ./terraform
          environment: prod
          aws-role-arn: arn:aws:iam::123456789:role/GitHubActionsRole

One line instead of 30. Changes to the action automatically propagate everywhere.

Building a Secret Rotation Action

Here’s a more sophisticated example — an action that rotates database credentials and updates them in AWS Secrets Manager:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# rotate-secrets/action.yml
name: 'Rotate Database Credentials'
description: 'Generate new DB password and update Secrets Manager'
inputs:
  secret-name:
    description: 'AWS Secrets Manager secret name'
    required: true
  db-host:
    description: 'Database hostname'
    required: true
  db-user:
    description: 'Database username'
    required: true

outputs:
  rotated:
    description: 'Whether rotation occurred'
    value: ${{ steps.rotate.outputs.rotated }}

runs:
  using: 'composite'
  steps:
    - name: Generate new password
      id: generate
      shell: bash
      run: |
        NEW_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
        echo "::add-mask::$NEW_PASS"
        echo "password=$NEW_PASS" >> $GITHUB_OUTPUT

    - name: Update database password
      id: rotate
      shell: bash
      env:
        PGPASSWORD: ${{ env.CURRENT_DB_PASSWORD }}
      run: |
        psql -h ${{ inputs.db-host }} -U ${{ inputs.db-user }} -d postgres \
          -c "ALTER USER ${{ inputs.db-user }} PASSWORD '${{ steps.generate.outputs.password }}';"
        echo "rotated=true" >> $GITHUB_OUTPUT

    - name: Update Secrets Manager
      shell: bash
      run: |
        aws secretsmanager put-secret-value \
          --secret-id ${{ inputs.secret-name }} \
          --secret-string '{"username":"${{ inputs.db-user }}","password":"${{ steps.generate.outputs.password }}"}'

JavaScript Actions for Complex Logic

When composite actions aren’t enough, JavaScript actions provide full programming capabilities:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// index.js
const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    const environment = core.getInput('environment');
    const octokit = github.getOctokit(process.env.GITHUB_TOKEN);
    
    // Create a deployment
    const deployment = await octokit.rest.repos.createDeployment({
      owner: github.context.repo.owner,
      repo: github.context.repo.repo,
      ref: github.context.sha,
      environment: environment,
      auto_merge: false,
      required_contexts: []
    });
    
    core.setOutput('deployment-id', deployment.data.id);
    core.info(`Created deployment ${deployment.data.id} for ${environment}`);
    
  } catch (error) {
    core.setFailed(error.message);
  }
}

run();

With the corresponding action.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
name: 'Create Deployment'
description: 'Create a GitHub deployment for environment tracking'
inputs:
  environment:
    required: true
outputs:
  deployment-id:
    description: 'The created deployment ID'
runs:
  using: 'node20'
  main: 'index.js'

Versioning Strategy

Tag your actions repository semantically:

1
2
3
4
5
6
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0

# Create a major version tag that moves with releases
git tag -fa v1 -m "Update v1 to latest"
git push origin v1 --force

Consumers reference @v1 for automatic minor/patch updates, or pin to @v1.0.0 for stability.

Testing Actions Locally

Use act to test workflows locally before pushing:

1
act -j deploy --secret-file .secrets

Conclusion

Custom GitHub Actions transform repetitive infrastructure tasks into maintainable, versioned code. Start with composite actions for simple orchestration, graduate to JavaScript for complex logic. Your future self (and teammates) will thank you when updating a deployment process means changing one file instead of fifty.

The real power emerges when you build an internal library of actions that encode your organization’s best practices — security policies, naming conventions, compliance checks — all enforced automatically on every commit.