# Migrate Bitbucket Pipelines to GitHub Actions

> Migrate Bitbucket Pipelines to GitHub Actions: steps, parallel, caches, artifacts, services, deployments and pipes mapped, plus a before and after.

Source: https://latchkey.dev/learn/migrate/bitbucket-pipelines-to-github-actions  
Updated: 2026-11-12

To migrate Bitbucket Pipelines to GitHub Actions, treat each Bitbucket step as a GitHub job, not a GitHub step: a Bitbucket step starts its own Docker container, so the files it shares travel as artifacts, exactly as between GitHub jobs. Then map `caches` to `actions/cache`, `services` to `services:` with mapped ports, `deployment` to `environment:` plus `concurrency:`, each pipe to an action or a CLI call, and `size` to a `runs-on` label.

A `bitbucket-pipelines.yml` reads like a GitHub workflow with different key names, and that is the trap. The unit of isolation is different: in Bitbucket, each step is a fresh container with a fresh clone, while a GitHub job keeps one workspace across all of its steps.

Get that one mapping right and most of the file follows. This page gives the full concept map, a checklist of what to collect before you start, and a working before and after with a service database, a cache, a parallel group, an artifact and an OIDC deploy.

## The concept map

The rows below follow Atlassian's step options reference and GitHub's workflow syntax, read 2026-11-12. Most rows are a rename; slow down where a Bitbucket step and a GitHub step behave differently, starting with the second row.

| Bitbucket Pipelines | GitHub Actions | What changes |
| --- | --- | --- |
| `default`, `branches`, `pull-requests`, `tags`, `custom` | `on: push`, `pull_request`, `push: tags`, `workflow_dispatch` | `default` runs on every push without a branch pipeline; GitHub has no fallback section |
| `step`, its own Docker container | A job, on its own runner | Files cross only as artifacts, in both |
| `script` lines | `run:` steps in one job | Steps in one job share the workspace |
| `image` | `container:`, or a setup action | Job containers need a Linux runner |
| `parallel` | Jobs with no `needs`, or a matrix | Bitbucket `fail-fast` defaults to `false`, a GitHub matrix to `true` |
| `caches`, with `key: files` | `actions/cache` with a `hashFiles()` key | Bitbucket saves under 1 GB compressed, clears after 1 week; GitHub 10 GB, 7 days unused |
| `artifacts` | `upload-artifact`, `download-artifact` | Bitbucket keeps 14 days and 1 GB; GitHub up to 90 days |
| `services` | `services:` | Map `ports:` to reach it on localhost; Bitbucket allows 5 per step |
| `deployment`, `trigger: manual` | `environment:` and `concurrency:` | Required reviewers on private repositories need GitHub Enterprise |
| `pipe` | `uses:` an action, or a CLI in `run:` | Pipe variables become `with:` inputs |
| `size: 1x` to `32x` | A `runs-on` label | `2x` is 4 CPU and 8 GB; `latchkey-small` is 2 vCPU and 8 GB |

> Bitbucket figures are from Atlassian Support: step options, cache dependencies, use artifacts in steps, databases and service containers, and deployment concurrency control. GitHub figures are from its workflow syntax and dependency caching references and the `retention-days` input of `actions/upload-artifact` v7.0.2. All read 2026-11-12.

## The migration checklist

Collect these from the Bitbucket repository settings and the YAML before you write the workflow.

- **Labels.** Every `size:` value and every self-hosted `runs-on:` label. Atlassian's table gives 1x as 2 CPU and 4 GB, 2x as 4 CPU and 8 GB, and 4x and above on paid plans only, at a multiple of the 1x build minutes.
- **Secrets.** Repository, workspace and deployment variables, marked Secured or not. Secured ones become GitHub secrets, deployment variables become environment secrets, and the rest become configuration variables.
- **Cache keys.** Predefined caches such as `node` have no key at all; Atlassian notes that predefined caches do not support file-based keys. Give each one a `hashFiles()` key in `actions/cache`, or use the `cache:` input of a setup action.
- **Matrix.** Bitbucket has no matrix: a `parallel` block of near-identical steps is one, written out by hand. Collapse it into `strategy.matrix`, and set `fail-fast: false` if a leg must not cancel the others.
- **Artifacts.** Every `artifacts:` path, and which later step reads it. Steps that only exist to pass `dist/` along can often merge into one job and drop the artifact.
- **Concurrency.** Bitbucket pauses a later deployment to an environment that already has one in progress, until someone resumes or reruns it. GitHub does nothing of the kind unless the deploy job sets `concurrency:`, and only `queue: max` keeps every waiting deploy.
- **Max time.** `max-time` defaults to 120 minutes in Bitbucket; `timeout-minutes` defaults to 360 in GitHub. Carry the shorter limit across.

## Before: bitbucket-pipelines.yml

Tests run on pull requests and on `main` against a Postgres service, a build runs beside them on `main`, and a manual deploy step pushes `dist/` to S3 through the `atlassian/aws-s3-deploy` pipe with OIDC. Note that the Build step and the Deploy step are separate containers, so `dist/**` has to be declared as an artifact to reach the deploy.

```bitbucket-pipelines.yml
image: node:22

definitions:
  caches:
    npm-lock:
      key:
        files:
          - package-lock.json
      path: ~/.npm
  services:
    postgres:
      image: postgres:16
      variables:
        POSTGRES_PASSWORD: test
  steps:
    - step: &test
        name: Test
        caches:
          - npm-lock
        services:
          - postgres
        script:
          - npm ci
          - npm test

pipelines:
  pull-requests:
    '**':
      - step: *test
  branches:
    main:
      - parallel:
          steps:
            - step: *test
            - step:
                name: Build
                caches:
                  - npm-lock
                script:
                  - npm ci
                  - npm run build
                artifacts:
                  - dist/**
      - step:
          name: Deploy
          deployment: production
          trigger: manual
          oidc: true
          script:
            - pipe: atlassian/aws-s3-deploy:2.1.0
              variables:
                AWS_DEFAULT_REGION: 'us-east-1'
                AWS_OIDC_ROLE_ARN: $AWS_DEPLOY_ROLE_ARN
                S3_BUCKET: $S3_BUCKET
                LOCAL_PATH: 'dist'
```

## After: .github/workflows/ci.yml

Three jobs. `test` runs on both events, `build` and `deploy` only on a push to `main`. The Postgres service gets `ports: 5432:5432`, because the job runs on the runner rather than in a container, and that mapping is what makes `localhost:5432` work as it did in Bitbucket. The health check holds the job until the database accepts connections.

The manual trigger becomes the `production` environment, which pauses the job for a reviewer when the environment has required reviewers; on a private repository that needs GitHub Enterprise, so check your plan. The pipe becomes `aws-actions/configure-aws-credentials` with `id-token: write`, followed by the AWS CLI call the pipe was making.

The `concurrency:` block stands in for Bitbucket's deployment lock. With `queue: max`, a second push to `main` waits for the first deploy and then starts on its own. Without it, GitHub keeps one pending run per group, and a third push cancels the second one while it waits.

```.github/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npm test

  build:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: npm
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: dist
          path: dist

  deploy:
    needs: [test, build]
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: production
      queue: max
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: dist
          path: dist
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1
      - run: aws s3 sync dist "s3://${{ vars.S3_BUCKET }}/"
```

> Bitbucket's paused deployments are from Atlassian Support, deployment concurrency control. The default `queue: single` and `queue: max` (up to 100 pending) are from [GitHub Docs: Control the concurrency of workflows and jobs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). Both read 2026-11-12.

## Pipes are the slow part

Atlassian describes a pipe as a script that lives in a Docker container, configured through `variables:`. There is no automatic translation, but most popular pipes wrap a vendor CLI, and the vendor usually publishes a GitHub action for the same job: credentials from one action, then the CLI in `run:`. Read the pipe's README for the command it runs and the defaults it sets; `aws-s3-deploy`, for example, documents a `DELETE_FLAG` that defaults to `false`, which is why the sync above has no `--delete`.

Bitbucket's OIDC token arrives as `BITBUCKET_STEP_OIDC_TOKEN` when a step sets `oidc: true`. On GitHub the token is requested by the action itself, and only when the job grants `id-token: write`. Your cloud's trust policy needs a new provider and a new subject either way, because the issuer changes.

## Caches: from named caches to keys

Bitbucket saves a cache only when it is empty and the build succeeds, and clears it after a week. `actions/cache` saves when no entry matches the exact key, which is why the key must change when the lockfile does. For npm, the `cache: npm` input on `actions/setup-node` does this for you; for anything else, write the key yourself.

```.github/workflows/ci.yml (cache step)
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
          restore-keys: npm-${{ runner.os }}-
```

## What GitHub Actions Importer covers

GitHub Actions Importer has a `bitbucket` subcommand, covered with its limits on [GitHub Actions Importer](/learn/migrate/github-actions-importer). It is worth running for the triggers, the steps and the caches. Check what it writes against the concept map: the step-to-job boundary, the service ports and the deployment concurrency are where a mechanical conversion is most likely to be correct YAML and wrong behavior.

> The pipe's `aws s3 sync` call and `DELETE_FLAG` default: the `atlassian/aws-s3-deploy` 2.1.0 README and `pipe.sh`, read 2026-11-12.

## FAQ

### What is the GitHub Actions equivalent of a Bitbucket pipe?

An action, used with `uses:` and configured with `with:`. Most pipes wrap a vendor CLI, so the usual translation is the vendor's credentials action followed by the same CLI command in a `run:` step, which also makes the command visible in the workflow file.

### How do I run steps in parallel in GitHub Actions like Bitbucket parallel?

Make them separate jobs with no `needs:` between them; GitHub starts independent jobs at the same time. When the parallel steps differ only in one value, such as a test shard, use `strategy.matrix`, and set `fail-fast: false` to keep Bitbucket's default of letting the other legs finish.

### How do I pass files between jobs in GitHub Actions?

Upload them with `actions/upload-artifact` in the job that makes them, and download them by name with `actions/download-artifact` in a job that lists the first one under `needs:`. Steps in one job need none of this, because they share the workspace, which is why merging two Bitbucket steps into one job can drop an artifact entirely.

### How do I use a service container like Postgres in GitHub Actions?

Declare it under the job's `services:` with an `image:` and `env:`. When the job runs directly on the runner, map the port with `ports: - 5432:5432` and connect to `localhost`; when the job runs in a `container:`, reach the service by its label as the host name instead.

## References

- [Atlassian Support: Bitbucket Pipelines step options, size and max-time (verified 2026-11-12)](https://support.atlassian.com/bitbucket-cloud/docs/step-options/)
- [Atlassian Support: cache dependencies in Bitbucket Pipelines (verified 2026-11-12)](https://support.atlassian.com/bitbucket-cloud/docs/cache-dependencies/)
- [Atlassian Support: use artifacts in steps, 14 days and 1 GB (verified 2026-11-12)](https://support.atlassian.com/bitbucket-cloud/docs/use-artifacts-in-steps/)
- [GitHub Docs: workflow syntax, services, containers and timeouts (verified 2026-11-12)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Docs: dependency caching reference, limits and eviction (verified 2026-11-12)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
