Building Your First Pipeline
Create a complete CI/CD workflow that tests, builds, and deploys a Node.js application to staging and production
With the core concepts understood, it's time to build a real-world pipeline. In this guide, we'll create a complete CI/CD workflow that tests a Node.js application, builds a Docker image, and deploys it to your server.
Project Structure
We'll work with a typical Node.js web application. Here's the structure we'll build CI/CD for:
my-node-app/
├── src/
│ └── index.js
├── tests/
│ └── app.test.js
├── Dockerfile
├── package.json
└── ci/
├── pipeline.yml
├── tasks/
│ ├── test.yml
│ ├── build-image.yml
│ └── deploy.yml
└── scripts/
├── run-tests.sh
├── build-docker.sh
└── deploy.shThe ci/ directory contains all Concourse-specific configurations, keeping your pipeline definitions version-controlled alongside your application code.
Creating Task Definitions
First, let's create the individual task definitions that our pipeline will use.
Test Task
platform: linux
image_resource:
type: registry-image
source:
repository: node
tag: "20-alpine"
inputs:
- name: source
caches:
- path: source/node_modules
run:
path: sh
args:
- -exc
- |
cd source
npm ci
npm run lint
npm testBuild Image Task
platform: linux
image_resource:
type: registry-image
source:
repository: concourse/oci-build-task
inputs:
- name: source
outputs:
- name: image
params:
CONTEXT: source
DOCKERFILE: source/Dockerfile
run:
path: buildDeploy Task
platform: linux
image_resource:
type: registry-image
source:
repository: alpine
tag: latest
inputs:
- name: source
params:
DEPLOY_HOST: ""
DEPLOY_USER: ""
SSH_PRIVATE_KEY: ""
IMAGE_TAG: ""
run:
path: sh
args:
- -exc
- |
apk add --no-cache openssh-client
# Setup SSH
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
# Deploy
ssh "$DEPLOY_USER@$DEPLOY_HOST" << EOF
docker pull myorg/my-node-app:${IMAGE_TAG}
docker stop my-node-app || true
docker rm my-node-app || true
docker run -d --name my-node-app -p 3000:3000 myorg/my-node-app:${IMAGE_TAG}
EOFThe Complete Pipeline
Now let's build the pipeline that orchestrates these tasks:
resource_types:
- name: slack-notification
type: registry-image
source:
repository: cfcommunity/slack-notification-resource
tag: latest
resources:
# Source code repository
- name: source-code
type: git
icon: github
source:
uri: ((git-uri))
branch: main
private_key: ((git-private-key))
# Docker registry for built images
- name: app-image
type: registry-image
icon: docker
source:
repository: ((docker-repo))
username: ((docker-username))
password: ((docker-password))
tag: latest
# Slack notifications
- name: slack
type: slack-notification
icon: slack
source:
url: ((slack-webhook-url))
# Semantic versioning
- name: version
type: semver
icon: tag
source:
driver: git
uri: ((git-uri))
branch: version
file: version
private_key: ((git-private-key))
jobs:
#
# TEST JOB
#
- name: test
public: true
plan:
- get: source-code
trigger: true
- task: run-tests
file: source-code/ci/tasks/test.yml
input_mapping:
source: source-code
on_failure:
put: slack
params:
text: |
:x: *Tests failed* for `((git-uri))`
Build: $ATC_EXTERNAL_URL/builds/$BUILD_ID
#
# BUILD JOB
#
- name: build
public: true
serial: true
plan:
- in_parallel:
- get: source-code
trigger: true
passed: [test]
- get: version
params: { bump: patch }
- task: build-docker-image
privileged: true
file: source-code/ci/tasks/build-image.yml
input_mapping:
source: source-code
- put: app-image
params:
image: image/image.tar
additional_tags: version/version
get_params:
skip_download: true
- put: version
params: { file: version/version }
on_success:
put: slack
params:
text: |
:white_check_mark: *Build successful*
Image: `((docker-repo)):$(cat version/version)`
on_failure:
put: slack
params:
text: |
:x: *Build failed* for `((git-uri))`
Build: $ATC_EXTERNAL_URL/builds/$BUILD_ID
#
# DEPLOY STAGING JOB
#
- name: deploy-staging
public: false
serial: true
plan:
- in_parallel:
- get: source-code
passed: [build]
- get: app-image
trigger: true
passed: [build]
- get: version
passed: [build]
- load_var: image-tag
file: version/version
- task: deploy-to-staging
file: source-code/ci/tasks/deploy.yml
input_mapping:
source: source-code
params:
DEPLOY_HOST: ((staging-host))
DEPLOY_USER: ((staging-user))
SSH_PRIVATE_KEY: ((staging-ssh-key))
IMAGE_TAG: ((.:image-tag))
on_success:
put: slack
params:
text: |
:rocket: *Deployed to staging*
Version: `((.:image-tag))`
URL: https://staging.example.com
#
# DEPLOY PRODUCTION JOB
#
- name: deploy-production
public: false
serial: true
plan:
- in_parallel:
- get: source-code
passed: [deploy-staging]
- get: app-image
passed: [deploy-staging]
- get: version
passed: [deploy-staging]
- load_var: image-tag
file: version/version
- task: deploy-to-production
file: source-code/ci/tasks/deploy.yml
input_mapping:
source: source-code
params:
DEPLOY_HOST: ((production-host))
DEPLOY_USER: ((production-user))
SSH_PRIVATE_KEY: ((production-ssh-key))
IMAGE_TAG: ((.:image-tag))
on_success:
put: slack
params:
text: |
:tada: *Deployed to production*
Version: `((.:image-tag))`
URL: https://app.example.com
on_failure:
put: slack
params:
text: |
:rotating_light: *PRODUCTION DEPLOY FAILED*
Build: $ATC_EXTERNAL_URL/builds/$BUILD_IDSetting Up Credentials
Before deploying the pipeline, configure your secrets. If you're not using a credential manager, create a variables file:
⚠️ Never commit credentials.yml to version control!
git-uri: git@github.com:myorg/my-node-app.git
git-private-key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...your private key...
-----END OPENSSH PRIVATE KEY-----
docker-repo: myorg/my-node-app
docker-username: myuser
docker-password: mypassword
slack-webhook-url: https://hooks.slack.com/services/XXX/YYY/ZZZ
staging-host: staging.example.com
staging-user: deploy
staging-ssh-key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...staging key...
-----END OPENSSH PRIVATE KEY-----
production-host: app.example.com
production-user: deploy
production-ssh-key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...production key...
-----END OPENSSH PRIVATE KEY-----Deploying the Pipeline
Now deploy your pipeline to Concourse:
# Login to Concourse
fly -t main login -c http://your-concourse:8080
# Set the pipeline with credentials
fly -t main set-pipeline \
-p my-node-app \
-c ci/pipeline.yml \
-l credentials.yml
# Review the diff and confirm (y)
# Unpause the pipeline
fly -t main unpause-pipeline -p my-node-appOnce deployed, visit your Concourse web UI to see the pipeline visualization. Each job will be shown as a box, with lines connecting them to show dependencies.
Understanding the Pipeline Flow
Here's how the pipeline executes:
┌─────────────────────────────────────────────────────────────┐
│ Pipeline Flow │
└─────────────────────────────────────────────────────────────┘
[source-code] ───trigger───> [test] ───passed───> [build] ───passed───> [deploy-staging]
│ │ │
│ [app-image] │
│ [version] │
│ │ │
└─────────────────────┴───────passed───> [deploy-production]
(manual trigger)Job Dependencies
1. test
2. build
3. deploy-staging
4. deploy-production
Pipeline Operations
Triggering Builds
# Manually trigger a job
fly -t main trigger-job -j my-node-app/test
# Trigger and watch output
fly -t main trigger-job -j my-node-app/test --watch
# Trigger production deployment (after staging verification)
fly -t main trigger-job -j my-node-app/deploy-productionViewing Build Output
# Watch a running build
fly -t main watch -j my-node-app/build
# View specific build
fly -t main watch -j my-node-app/build -b 42
# Get build logs
fly -t main builds -j my-node-app/testPausing and Unpausing
# Pause a job (prevents triggering)
fly -t main pause-job -j my-node-app/deploy-production
# Unpause
fly -t main unpause-job -j my-node-app/deploy-production
# Pause entire pipeline
fly -t main pause-pipeline -p my-node-appIntercepting Containers
Debug failed builds by accessing the container:
# List containers from recent builds
fly -t main containers
# Intercept a specific build's container
fly -t main intercept -j my-node-app/test -s run-tests
# You're now in the container
ls -la source/
cat source/package.json
npm test # Re-run tests interactivelyAdding Pipeline Groups
For complex pipelines, organize jobs into visual groups:
groups:
- name: development
jobs:
- test
- build
- name: deployment
jobs:
- deploy-staging
- deploy-production
- name: all
jobs:
- test
- build
- deploy-staging
- deploy-production
# ... rest of pipelineGroups create tabs in the web UI, making large pipelines navigable.
Common Patterns
Running Jobs in Parallel
Speed up pipelines by parallelizing independent steps
- name: test
plan:
- get: source-code
trigger: true
- in_parallel:
- task: lint
file: source-code/ci/tasks/lint.yml
- task: unit-tests
file: source-code/ci/tasks/unit-test.yml
- task: integration-tests
file: source-code/ci/tasks/integration-test.ymlConditional Steps with try
Allow non-critical steps to fail
- name: build
plan:
- get: source-code
- task: build
file: source-code/ci/tasks/build.yml
- try:
task: upload-coverage
file: source-code/ci/tasks/coverage.ymlAggregating Multiple Resources
Build matrix-style pipelines
resources:
- name: node-18
type: registry-image
source:
repository: node
tag: "18"
- name: node-20
type: registry-image
source:
repository: node
tag: "20"
jobs:
- name: test-matrix
plan:
- get: source-code
trigger: true
- in_parallel:
- task: test-node-18
image: node-18
file: source-code/ci/tasks/test.yml
- task: test-node-20
image: node-20
file: source-code/ci/tasks/test.ymlTime-Based Triggers
Run jobs on a schedule
resources:
- name: nightly
type: time
source:
start: 2:00 AM
stop: 3:00 AM
location: America/New_York
jobs:
- name: nightly-build
plan:
- get: nightly
trigger: true
- get: source-code
- task: full-test-suite
file: source-code/ci/tasks/full-tests.ymlDebugging Tips
Check Resource Versions
# See what versions Concourse knows about
fly -t main resource-versions -r my-node-app/source-code
# Check why a resource isn't triggering
fly -t main check-resource -r my-node-app/source-codeValidate Pipeline Syntax
# Validate before setting
fly -t main validate-pipeline -c ci/pipeline.yml
# Format pipeline YAML
fly -t main format-pipeline -c ci/pipeline.ymlView Job Configuration
# See effective job config
fly -t main get-pipeline -p my-node-appNext Steps
You now have a fully functional CI/CD pipeline! In Part 4, we'll explore advanced patterns including fan-in/fan-out workflows, dynamic pipelines, and multi-environment deployments.
