How to Install Concourse CI on Ubuntu 24.04 VPS: Pipelines as YAML with Resource-Driven Builds
Concourse CI is a pipeline-first continuous integration system that treats every build as a pure function of declared inputs. No hidden state, no snowflake build agents, no plugin soup — just YAML pipelines, container-isolated tasks, and a small set of resource types that version everything they touch. If you have outgrown shell-script CI but do not want to inherit the operational weight of Jenkins, Concourse is the sweet spot.
This guide walks you through a production-grade install on a single Ubuntu 24.04 VPS: Docker Compose stack (web + worker + Postgres), the fly CLI, a working pipeline with real resources, authentication via GitHub or OIDC, Vault-backed secrets, an Nginx TLS reverse proxy, team isolation, and horizontal worker scaling.
Need a VPS first? The CloudCore Professional plan (6 vCPU, 12 GB RAM, 100 GB NVMe, EUR 19.99/month) is sized precisely for a Concourse web node with a couple of concurrent workers and room for heavier build images.
Table of Contents
What is Concourse CI?
Concourse is an open-source CI/CD platform originally developed at Pivotal to build and ship Cloud Foundry. Its core idea is that a pipeline should be a declarative, versioned description of how code flows from commit to production — not a dashboard-configured black box.
A Concourse pipeline is built from three primitives:
- Resources — external state that the pipeline reads from or writes to. A git repository, a Docker image tag, a scheduled time interval, an S3 bucket, or a Slack channel are all resources. Resources are versioned: every commit, every image tag, every new file is a new resource version.
- Jobs — sequences of work that consume resource versions, run tasks, and optionally produce new resource versions. A job's inputs and outputs are fully declared; nothing is implicit.
- Tasks — individual units of execution. Every task runs in a fresh container built from a declared image, with explicit inputs mounted in and explicit outputs captured. Tasks are pure functions: same inputs, same script, same outputs.
Why Self-Host Concourse on Your VPS?
Hosted CI services (CircleCI, GitHub Actions, Travis) are convenient but come with real tradeoffs: per-minute billing, concurrency caps, opaque build minutes accounting, and your source code touching infrastructure you do not control. Running Concourse on your own VPS gives you:
- Flat-rate pricing — unlimited builds, unlimited minutes. A EUR 19.99/month VPS replaces a $300/month CI bill the moment you cross roughly 2,000 build minutes.
- No concurrency caps — run as many parallel jobs as your worker count allows.
- Source code stays on your infrastructure — proprietary code, signing keys, and secrets never transit a third-party CI vendor.
- Declarative pipelines as code — every pipeline is a YAML file in git. Reviewable, diffable, auditable.
- Reproducible builds — every task runs in a fresh container. No "works on the build agent" drift.
- Resource-driven triggers — Concourse does not just poll git. It watches S3 buckets, Docker registries, time windows, and custom resource types, triggering pipelines when anything meaningful changes.
- Strong team isolation — multi-team deployments are built in, each with their own pipelines, auth, and RBAC.
Concourse vs. Jenkins vs. Drone
| Dimension | Concourse CI | Jenkins | Drone CI |
|---|---|---|---|
| Configuration model | Declarative YAML | Imperative Groovy / UI | Declarative YAML |
| Isolation per task | Always containerized | Optional (agents) | Always containerized |
| Plugin ecosystem | Small, typed resource types | Massive, variable quality | Small, plugin system |
| State on disk | Only Postgres + workers | Huge $JENKINS_HOME | Small |
| Best for | Teams wanting reproducible, versioned pipelines | Legacy shops, heavy integrations | Lightweight Docker-native CI |
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to the server
- At least 4 vCPU and 8 GB RAM (6 vCPU / 12 GB RAM strongly recommended for anything beyond hobby builds)
- At least 40 GB free disk space for Docker images, Postgres, and build caches
- A domain name pointed at the VPS (for TLS; A record to your server IP)
- Outbound internet access on ports 443 and 2222
Recommended Plan: CloudCore Professional>
For a comfortable Concourse deployment with 2-3 concurrent workers, we recommend CloudCore Professional:>
- 6 vCPU cores
- 12 GB RAM
- 100 GB NVMe SSD
- Unmetered bandwidth
- EUR 19.99/month
Connect to your server:
ssh root@your-server-ipStep 1: Update the System and Install Docker
Update the system first.
sudo apt update && sudo apt upgrade -yInstall Docker Engine and the Compose plugin (skip this if you already followed our Docker install guide):
sudo apt install -y ca-certificates curl gnupgsudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Verify:
docker --version
docker compose versionExpected output:
Docker version 27.3.1, build ce12230
Docker Compose version v2.29.7Concourse's worker runs privileged containers (it uses runc + its own garden runtime inside Docker), so keep Docker on the default root-run configuration for this install.
Step 2: Deploy Concourse with Docker Compose
Create a directory for the stack:
sudo mkdir -p /opt/concourse
cd /opt/concourseConcourse's web node authenticates workers using an SSH key pair, and signs session tokens using another key pair. Generate them inside a keys/ folder.
sudo mkdir -p /opt/concourse/keys/web /opt/concourse/keys/workersudo ssh-keygen -t rsa -q -N '' -f /opt/concourse/keys/web/tsa_host_key sudo ssh-keygen -t rsa -q -N '' -f /opt/concourse/keys/web/session_signing_key sudo ssh-keygen -t rsa -q -N '' -f /opt/concourse/keys/worker/worker_key
sudo cp /opt/concourse/keys/worker/worker_key.pub /opt/concourse/keys/web/authorized_worker_keys sudo cp /opt/concourse/keys/web/tsa_host_key.pub /opt/concourse/keys/worker/tsa_host_key.pub
Now create the Compose file at /opt/concourse/docker-compose.yml:
services: concourse-db: image: postgres:16 environment: POSTGRES_DB: concourse POSTGRES_USER: concourse POSTGRES_PASSWORD: changeme-strong-password volumes: - concourse-db-data:/var/lib/postgresql/data restart: unless-stoppedconcourse-web: image: concourse/concourse:7.11 command: web depends_on: [concourse-db] ports: - "127.0.0.1:8080:8080" # bound to localhost; Nginx will front it - "2222:2222" # TSA (worker registration) — open to the net if you scale workers off-box environment: CONCOURSE_EXTERNAL_URL: "https://ci.yourdomain.com" CONCOURSE_POSTGRES_HOST: concourse-db CONCOURSE_POSTGRES_USER: concourse CONCOURSE_POSTGRES_PASSWORD: changeme-strong-password CONCOURSE_POSTGRES_DATABASE: concourse CONCOURSE_ADD_LOCAL_USER: "admin:changeme-admin-password" CONCOURSE_MAIN_TEAM_LOCAL_USER: admin CONCOURSE_CLUSTER_NAME: "vps-server-ci" CONCOURSE_ENABLE_GLOBAL_RESOURCES: "true" volumes: - /opt/concourse/keys/web:/concourse-keys restart: unless-stopped
concourse-worker: image: concourse/concourse:7.11 command: worker privileged: true depends_on: [concourse-web] environment: CONCOURSE_TSA_HOST: concourse-web:2222 CONCOURSE_RUNTIME: "containerd" CONCOURSE_WORK_DIR: /worker-state CONCOURSE_BIND_IP: 0.0.0.0 CONCOURSE_BAGGAGECLAIM_BIND_IP: 0.0.0.0 volumes: - /opt/concourse/keys/worker:/concourse-keys - concourse-worker-state:/worker-state restart: unless-stopped
volumes: concourse-db-data: concourse-worker-state:
Change the two changeme-* passwords and CONCOURSE_EXTERNAL_URL to your actual domain, then launch:
cd /opt/concourse
sudo docker compose up -dWatch the logs until the web node reports it is ready:
sudo docker compose logs -f concourse-webYou are looking for a line like:
{"timestamp":"...","level":"info","source":"atc","message":"atc.listening","data":{"http":"0.0.0.0:8080"}}Confirm the worker has registered:
sudo docker compose logs concourse-web | grep "worker registered"At this point the web UI is accessible on http://127.0.0.1:8080 from the VPS. We will expose it with TLS in Step 8.
Step 3: Install the fly CLI and Log In
fly is Concourse's command-line client — it is how you push pipelines, view builds, hijack failed containers, and manage teams.
Download it from your running web node (the CLI version must match the server):
curl -L -o /tmp/fly "http://127.0.0.1:8080/api/v1/cli?arch=amd64&platform=linux"
sudo install -m 0755 /tmp/fly /usr/local/bin/fly
fly --versionExpected output:
7.11.2Log in to the main team as the admin user you defined in Compose:
fly -t vps login -c http://127.0.0.1:8080 -u admin -p changeme-admin-passwordThe -t vps is a local target alias — subsequent commands reference it with fly -t vps .... Verify:
fly -t vps workersExpected output:
name containers platform tags team state version
b3c02e... 0 linux none none running 2.3Step 4: Write Your First Pipeline
Create a working file at ~/hello-pipeline.yml:
resources: - name: every-5m type: time source: interval: 5m
jobs: - name: say-hello plan: - get: every-5m trigger: true - task: greet config: platform: linux image_resource: type: registry-image source: repository: busybox tag: latest run: path: sh args: - -ec - | echo "Hello from Concourse on $(hostname)!" date -u
Push it:
fly -t vps set-pipeline -p hello -c ~/hello-pipeline.ymlYou will see a diff of what will change. Type y to apply. Then unpause the pipeline:
fly -t vps unpause-pipeline -p helloWithin five minutes the say-hello job will trigger automatically. You can force a run now:
fly -t vps trigger-job -j hello/say-hello --watchYou will see the busybox container pull, the script execute, and the build succeed. Congratulations — you have a pipeline.
Step 5: Configure Resources (git, docker-image, time)
The time resource you just used is the simplest. Real pipelines consume and produce git commits, Docker images, S3 artifacts, and more. Below is a realistic pipeline that builds a Docker image from a git repo every time main gets a new commit.
Save as ~/app-pipeline.yml:
resources: - name: app-src type: git icon: github source: uri: https://github.com/your-org/your-app.git branch: main # For private repos: # private_key: ((github.deploy_key))- name: app-image type: registry-image icon: docker source: repository: ghcr.io/your-org/your-app username: ((ghcr.username)) password: ((ghcr.token))
- name: nightly type: time source: start: "02:00" stop: "03:00" location: UTC
jobs: - name: test plan: - get: app-src trigger: true - task: run-tests config: platform: linux image_resource: type: registry-image source: { repository: node, tag: "20-alpine" } inputs: - name: app-src run: path: sh dir: app-src args: - -ec - | npm ci npm test
- name: build-and-push plan: - get: app-src passed: [test] trigger: true - task: build privileged: true config: platform: linux image_resource: type: registry-image source: { repository: concourse/oci-build-task } inputs: - name: app-src path: . outputs: - name: image run: path: build - put: app-image params: image: image/image.tar
- name: nightly-security-scan plan: - get: nightly trigger: true - get: app-image passed: [build-and-push] - task: trivy-scan config: platform: linux image_resource: type: registry-image source: { repository: aquasec/trivy } inputs: - name: app-image run: path: trivy args: ["image", "--input", "app-image/image.tar"]
What is happening here:
- Three resources — source code (git), the published image (registry-image), and a nightly time window.
- Three jobs chained by
passed:— a commit flows fromtesttobuild-and-push, and only images that passed both gate intonightly-security-scan. get/put/taskare the only step types you really need in 90% of pipelines.((ghcr.token))is a secret reference — resolved at runtime from Vault (see Step 7).
fly -t vps set-pipeline -p my-app -c ~/app-pipeline.yml
fly -t vps unpause-pipeline -p my-appOpen https://ci.yourdomain.com/teams/main/pipelines/my-app and you will see a dependency graph rendered from the YAML — boxes for resources, circles for jobs, arrows for passed: constraints.
Other built-in resource types
| Type | Use case |
|---|---|
git | Git repository commits |
registry-image | Docker/OCI image tags |
s3 | S3 bucket objects (versions by version_id or regex) |
time | Scheduled triggers and time windows |
semver | Semantic version numbers (bumping, tagging) |
github-release | GitHub Releases |
pool | Exclusive locks across pipelines |
mock | Testing pipelines |
Step 6: Authentication (Local, GitHub, OIDC)
The admin local user is fine for getting started, but real teams need SSO.
Local users
Add more local users by extending CONCOURSE_ADD_LOCAL_USER in Compose (colon-separated, comma-delimited):
CONCOURSE_ADD_LOCAL_USER: "admin:adminpass,alice:alicepass,bob:bobpass"Restart:
sudo docker compose up -d concourse-webGitHub OAuth
Register an OAuth App at https://github.com/settings/developers:
- Homepage URL:
https://ci.yourdomain.com - Authorization callback URL:
https://ci.yourdomain.com/sky/issuer/callback
environment:
CONCOURSE_GITHUB_CLIENT_ID: "Iv1.xxxxxxxxxxxxxxxx"
CONCOURSE_GITHUB_CLIENT_SECRET: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
CONCOURSE_MAIN_TEAM_GITHUB_USER: "your-github-username"
CONCOURSE_MAIN_TEAM_GITHUB_ORG: "your-org:engineering"Restart the web container. The login page will now show a "Login with GitHub" button, and membership in the your-org GitHub org's engineering team will grant access to the main Concourse team.
OIDC (Okta, Keycloak, Auth0, Google)
For OIDC-compatible identity providers, register a client and add:
environment:
CONCOURSE_OIDC_DISPLAY_NAME: "Okta"
CONCOURSE_OIDC_ISSUER: "https://your-tenant.okta.com"
CONCOURSE_OIDC_CLIENT_ID: "0oa..."
CONCOURSE_OIDC_CLIENT_SECRET: "..."
CONCOURSE_OIDC_SCOPE: "openid,profile,email,groups"
CONCOURSE_MAIN_TEAM_OIDC_GROUP: "concourse-admins"You can combine providers — users will see multiple login buttons. Per-team authorization (e.g. CONCOURSE_MAIN_TEAM_OIDC_GROUP) binds identity groups to Concourse teams.
Step 7: Secrets Management with Vault
Hardcoding ((ghcr.token)) into pipelines via a variable file works, but real production uses a secrets backend. Concourse supports HashiCorp Vault, AWS SSM, AWS Secrets Manager, Kubernetes, and CredHub. Here is Vault.
Add a dev-mode Vault to docker-compose.yml (for production, use a real HA Vault deployment):
vault:
image: hashicorp/vault:1.17
cap_add: [IPC_LOCK]
environment:
VAULT_DEV_ROOT_TOKEN_ID: "root-token-change-me"
VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
ports: ["127.0.0.1:8200:8200"]
restart: unless-stoppedBring it up:
sudo docker compose up -d vaultLoad a few secrets:
export VAULT_ADDR=http://127.0.0.1:8200 export VAULT_TOKEN=root-token-change-me
sudo docker compose exec vault vault secrets enable -version=1 -path=concourse kv sudo docker compose exec vault vault write concourse/main/ghcr \ username=your-gh-user token=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx sudo docker compose exec vault vault write concourse/main/my-app/github \ deploy_key=@/dev/stdin < ~/.ssh/github_deploy_key
Concourse looks up secrets in this order (for a pipeline my-app in team main with variable ((foo.bar))):
concourse/main/my-app/foo → key barconcourse/main/foo → key barConfigure the web node to talk to Vault by adding to the concourse-web environment:
CONCOURSE_VAULT_URL: "http://vault:8200"
CONCOURSE_VAULT_CLIENT_TOKEN: "root-token-change-me"
CONCOURSE_VAULT_PATH_PREFIX: "concourse"
CONCOURSE_VAULT_LOOKUP_TEMPLATES: "/{{.Team}}/{{.Pipeline}}/{{.Secret}},/{{.Team}}/{{.Secret}}"For production, use Vault's AppRole auth method instead of a root token:
CONCOURSE_VAULT_AUTH_BACKEND: "approle"
CONCOURSE_VAULT_AUTH_PARAM: "role_id:xxx,secret_id:yyy"Restart web:
sudo docker compose up -d concourse-webNow ((ghcr.token)) in your pipeline resolves from Vault at build time — the secret never lands in the Concourse database or the pipeline YAML. Rotate in Vault and the next build picks up the new value.
Step 8: Front with Nginx and TLS
Install Nginx and Certbot:
sudo apt install -y nginx certbot python3-certbot-nginxPoint your DNS A record (ci.yourdomain.com) at the VPS IP and wait for propagation.
Create /etc/nginx/sites-available/concourse:
server { listen 443 ssl http2; server_name ci.yourdomain.com;ssl_certificate /etc/letsencrypt/live/ci.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ci.yourdomain.com/privkey.pem;
# Security headers add_header X-Content-Type-Options nosniff; add_header X-Frame-Options SAMEORIGIN; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 100m;
location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# Websockets for build log streaming proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 86400s; } }
server { listen 80; server_name ci.yourdomain.com; return 301 https://$host$request_uri; }
Enable and issue the cert:
sudo ln -s /etc/nginx/sites-available/concourse /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo certbot --nginx -d ci.yourdomain.com
sudo nginx -t && sudo systemctl reload nginxUpdate the CONCOURSE_EXTERNAL_URL in Compose to your HTTPS URL and restart:
sudo sed -i 's|CONCOURSE_EXTERNAL_URL:.*|CONCOURSE_EXTERNAL_URL: "https://ci.yourdomain.com"|' /opt/concourse/docker-compose.yml
sudo docker compose up -d concourse-webLog back in via the public URL:
fly -t vps login -c https://ci.yourdomain.com -u adminThe UI is now TLS-terminated, and websocket build-log streaming works through the proxy.
Step 9: Teams and Multi-Tenant Isolation
A Concourse "team" is a hard authorization boundary: pipelines, workers (optionally), and users are partitioned per team. Use teams to separate environments (prod, staging) or tenants.
Create a new team:
fly -t vps set-team -n platform \
--local-user alice \
--github-org your-org:platform-teamLog in to it:
fly -t platform-vps login -c https://ci.yourdomain.com -n platform -u alicePush a pipeline that belongs to that team:
fly -t platform-vps set-pipeline -p api -c ~/api-pipeline.ymlPipelines in platform cannot see secrets, pipelines, or build history in main — and vice versa. Vault lookups use the team name in the path, so each team gets its own secret namespace automatically.
RBAC roles within a team
Concourse supports four roles per team: owner, member, pipeline-operator, viewer. Assign them when creating the team:
fly -t vps set-team -n platform \
--local-user alice \
--github-org your-org:platform-team \
--github-team your-org:platform-viewers=viewerviewer can see builds but not trigger them; pipeline-operator can trigger jobs but not edit pipelines; member can edit pipelines but not manage team membership; owner can do everything including deleting the team.
Step 10: Scale Workers Horizontally
When your build queue starts backing up, add workers. Two paths:
Path A: More workers on the same VPS
Edit docker-compose.yml and bump the worker — or add a second worker service:
concourse-worker-2: image: concourse/concourse:7.11 command: worker privileged: true depends_on: [concourse-web] environment: CONCOURSE_TSA_HOST: concourse-web:2222 CONCOURSE_RUNTIME: containerd CONCOURSE_WORK_DIR: /worker-state CONCOURSE_NAME: worker-2 volumes: - /opt/concourse/keys/worker:/concourse-keys - concourse-worker-2-state:/worker-state restart: unless-stopped
volumes: concourse-worker-2-state:
sudo docker compose up -d
fly -t vps workersPath B: Workers on additional VPS nodes
Spin up a second VPS (same Ubuntu 24.04), install Docker, copy the worker keys (/opt/concourse/keys/worker/ and tsa_host_key.pub) to it, and run:
docker run -d --name concourse-worker \
--restart unless-stopped \
--privileged \
-v /opt/concourse-keys:/concourse-keys \
-v concourse-worker-state:/worker-state \
-e CONCOURSE_TSA_HOST=ci.yourdomain.com:2222 \
-e CONCOURSE_RUNTIME=containerd \
-e CONCOURSE_WORK_DIR=/worker-state \
-e CONCOURSE_NAME=worker-edge-1 \
concourse/concourse:7.11 workerThe worker connects outbound to port 2222 (TSA), registers itself, and immediately starts pulling jobs. No further configuration needed.
Team-scoped workers
Pin workers to a specific team (e.g. isolate prod workloads):
-e CONCOURSE_TEAM=prodWorker tags
Tag workers for specific workloads (GPU builds, macOS, etc.):
-e CONCOURSE_TAG=gpu,cuda12Then in a pipeline task:
task: train
tags: [gpu]Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Worker stays stalled in fly workers | Web node cannot reach worker, or vice versa | Check docker compose logs concourse-worker; verify CONCOURSE_TSA_HOST is reachable; fly prune-worker -w NAME to evict. |
"no versions available" on get step | Resource has not fetched yet, or check is failing | fly check-resource -r pipeline/resource; inspect the check's logs in the UI. |
Builds fail with cannot allocate memory | Worker host out of RAM | Reduce concurrent builds, add swap, or scale workers across VPS nodes. |
fly sync mismatch warning | CLI version older than server | fly -t vps sync to auto-update the binary. |
Secrets ((foo.bar)) not resolving | Vault path or prefix wrong | Check CONCOURSE_VAULT_PATH_PREFIX and CONCOURSE_VAULT_LOOKUP_TEMPLATES; use fly get-pipeline -p foo to see unresolved refs. |
| Websocket build logs stall behind Nginx | Missing Upgrade / Connection headers | Ensure the Nginx config in Step 8 includes both and proxy_read_timeout 86400s. |
oci-build-task fails with "operation not permitted" | Task missing privileged: true | Add privileged: true at the task level; required for the BuildKit-based build task. |
| Disk filling up on worker | Stale build containers and cached volumes | fly -t vps prune-worker -w NAME; Concourse cleans automatically but aggressive GC can be tuned with CONCOURSE_GARDEN_DNS_SERVER sibling settings. |
Useful diagnostic commands
# Hijack a failing task's container to poke around
fly -t vps hijack -j my-app/testWatch a running build
fly -t vps watch -j my-app/build-and-pushDump effective pipeline config (secrets unresolved)
fly -t vps get-pipeline -p my-appValidate a pipeline file without pushing
fly -t vps validate-pipeline -c ~/app-pipeline.ymlFAQ
What resources do I need to run Concourse CI?
A single-node Concourse install — web, worker, and Postgres — comfortably runs on a 6 vCPU / 12 GB RAM VPS like the CloudCore Professional plan. The web and Postgres containers together idle around 500-800 MB of RAM. Each concurrent build adds whatever your task image needs (a typical Node or Go build uses 512 MB-1 GB). Plan for 2-3 parallel builds on 12 GB of RAM; scale out horizontally once you cross that.
How is Concourse different from Jenkins or Drone?
Concourse treats every pipeline as declarative YAML with first-class resources (git, time, docker-image, S3, semver). There is no build server state to manage — pipelines are versioned, reproducible, and every task runs in an isolated container. Jenkins is imperative, plugin-heavy, and accumulates state in $JENKINS_HOME. Drone is closer to Concourse philosophically but is simpler and less opinionated about resources — it triggers on webhooks rather than polling versioned resources. Concourse's killer feature is the passed: constraint, which lets one resource version gate through multiple jobs in a provably consistent way.
Can I use Concourse with GitHub and GitLab?
Yes. The git resource type works with any Git provider over SSH or HTTPS — GitHub, GitLab, Bitbucket, Gitea, self-hosted. Authentication for the resource uses private_key (SSH) or username/password (HTTPS). For login to the web UI, Concourse supports local users, GitHub OAuth, GitLab OAuth, OIDC (Okta, Keycloak, Auth0, Google Workspace), LDAP, and Microsoft identity providers. You can mix and match.
How do I store secrets securely in Concourse?
Concourse integrates with HashiCorp Vault, AWS SSM Parameter Store, AWS Secrets Manager, Kubernetes secrets, and CredHub. Pipelines reference secrets via ((path/to/secret)) — Concourse fetches them at build time and never persists the values in the Postgres database or pipeline YAML. Secrets are namespaced by team and pipeline, so team isolation extends naturally to the secrets backend. For production, use Vault with AppRole authentication rather than static tokens.
How do I scale Concourse for more builds?
Add more worker containers. Each worker registers with the web node over TSA (port 2222) and autonomously pulls jobs from a queue. You can run multiple workers on one VPS (until you saturate CPU, RAM, or disk) or run workers across many VPS nodes — the web node does not care. Scaling is linear: double the workers, double the build throughput. For team isolation, pin workers to specific teams with CONCOURSE_TEAM=teamname. For specialized hardware (GPUs, ARM builders), tag workers and reference the tag in the task's tags: field.
Does Concourse support matrix builds?
Concourse does not have a first-class "matrix" syntax like GitHub Actions, but you achieve the same effect by declaring multiple jobs or using across: — a step modifier that fans out over a list of values. For example, testing against Node 18, 20, and 22 is a single job with an across: [[{var: node_version, values: [18, 20, 22]}]] clause. Combined with the passed: constraint, matrix builds compose cleanly with the rest of your pipeline.
How do I back up Concourse?
The only persistent state is Postgres (everything else — workers, build containers — is ephemeral). Back up the concourse-db-data volume with pg_dump on a schedule:
sudo docker compose exec concourse-db pg_dump -U concourse concourse > /backups/concourse-$(date +%F).sqlTo restore, spin up a fresh stack and psql the dump back in. Pipelines themselves live in git — they are YAML files — so redeploying them is just fly set-pipeline.
Next Steps
Now that Concourse is running, here is where to go next:
- Add pull request gating — install the
pull-requestresource type to run pipelines on every PR and post build status back to GitHub/GitLab. - Set up build metrics — Concourse exposes Prometheus metrics at
/api/v1/info/cli. Wire them into Grafana for build duration histograms, queue depth, and worker utilization. - Add Slack notifications — use the
slack-notificationcommunity resource type to post success/failure messages to channels. - Try ephemeral workers on spot/preemptible nodes — Concourse workers register and deregister cleanly, making them a good fit for spot-instance build fleets.
- Compare with Drone CI — if your use case is simpler, Drone's
.drone.yml-per-repo model may fit better. - Compare with Jenkins — Jenkins wins on plugin ecosystem; Concourse wins on reproducibility and YAML-native pipelines.
- Read the official docs — the Concourse documentation is excellent, particularly the "Around the House" section on
across:,try:, andensure:step modifiers.
Ready to run Concourse CI on production-grade infrastructure?>
The CloudCore Professional plan gives you 6 vCPU, 12 GB RAM, and 100 GB NVMe — sized precisely for a Concourse web node plus 2-3 concurrent workers — at EUR 19.99/month, flat. No per-minute CI billing. No build quota surprises. Deploy in minutes and own your pipelines end to end.>
Get Started with CloudCore Professional