Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install Concourse Ubuntu
GUIDEInstall Guides

How to Install Concourse CI on Ubuntu 24.04 VPS: Pipelines as YAML with Resource-Driven Builds

25 min read

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?
  • Why Self-Host Concourse on Your VPS?
  • Prerequisites
  • Step 1: Update the System and Install Docker
  • Step 2: Deploy Concourse with Docker Compose
  • Step 3: Install the fly CLI and Log In
  • Step 4: Write Your First Pipeline
  • Step 5: Configure Resources (git, docker-image, time)
  • Step 6: Authentication (Local, GitHub, OIDC)
  • Step 7: Secrets Management with Vault
  • Step 8: Front with Nginx and TLS
  • Step 9: Teams and Multi-Tenant Isolation
  • Step 10: Scale Workers Horizontally
  • Troubleshooting
  • FAQ
  • Next Steps
  • 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.
    The result is a CI system where you can delete every pipeline, every build history, and every worker — then redeploy from a YAML file in git and be back where you started in ten minutes.

    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

    DimensionConcourse CIJenkinsDrone CI
    Configuration modelDeclarative YAMLImperative Groovy / UIDeclarative YAML
    Isolation per taskAlways containerizedOptional (agents)Always containerized
    Plugin ecosystemSmall, typed resource typesMassive, variable qualitySmall, plugin system
    State on diskOnly Postgres + workersHuge $JENKINS_HOMESmall
    Best forTeams wanting reproducible, versioned pipelinesLegacy shops, heavy integrationsLightweight Docker-native CI
    If you need a comparison with simpler tools, see our guides on Drone CI and Jenkins. For the Docker foundation this guide depends on, see How to Install Docker on Ubuntu.

    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:

    bash
    ssh root@your-server-ip

    Step 1: Update the System and Install Docker

    Update the system first.

    bash
    sudo apt update && sudo apt upgrade -y

    Install Docker Engine and the Compose plugin (skip this if you already followed our Docker install guide):

    bash
    sudo apt install -y ca-certificates curl gnupg

    sudo 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:

    bash
    docker --version
    docker compose version

    Expected output:

    text
    Docker version 27.3.1, build ce12230
    Docker Compose version v2.29.7

    Concourse'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:

    bash
    sudo mkdir -p /opt/concourse
    cd /opt/concourse

    Concourse's web node authenticates workers using an SSH key pair, and signs session tokens using another key pair. Generate them inside a keys/ folder.

    bash
    sudo mkdir -p /opt/concourse/keys/web /opt/concourse/keys/worker

    sudo 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:

    yaml
    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-stopped

    concourse-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:

    bash
    cd /opt/concourse
    sudo docker compose up -d

    Watch the logs until the web node reports it is ready:

    bash
    sudo docker compose logs -f concourse-web

    You are looking for a line like:

    text
    {"timestamp":"...","level":"info","source":"atc","message":"atc.listening","data":{"http":"0.0.0.0:8080"}}

    Confirm the worker has registered:

    bash
    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):

    bash
    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 --version

    Expected output:

    text
    7.11.2

    Log in to the main team as the admin user you defined in Compose:

    bash
    fly -t vps login -c http://127.0.0.1:8080 -u admin -p changeme-admin-password

    The -t vps is a local target alias — subsequent commands reference it with fly -t vps .... Verify:

    bash
    fly -t vps workers

    Expected output:

    text
    name                                containers  platform  tags  team  state    version
    b3c02e...                           0           linux     none  none  running  2.3

    Step 4: Write Your First Pipeline

    Create a working file at ~/hello-pipeline.yml:

    yaml
    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:

    bash
    fly -t vps set-pipeline -p hello -c ~/hello-pipeline.yml

    You will see a diff of what will change. Type y to apply. Then unpause the pipeline:

    bash
    fly -t vps unpause-pipeline -p hello

    Within five minutes the say-hello job will trigger automatically. You can force a run now:

    bash
    fly -t vps trigger-job -j hello/say-hello --watch

    You 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:

    yaml
    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 from test to build-and-push, and only images that passed both gate into nightly-security-scan.
    • get / put / task are 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).
    Push it:

    bash
    fly -t vps set-pipeline -p my-app -c ~/app-pipeline.yml
    fly -t vps unpause-pipeline -p my-app

    Open 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

    TypeUse case
    gitGit repository commits
    registry-imageDocker/OCI image tags
    s3S3 bucket objects (versions by version_id or regex)
    timeScheduled triggers and time windows
    semverSemantic version numbers (bumping, tagging)
    github-releaseGitHub Releases
    poolExclusive locks across pipelines
    mockTesting pipelines
    Community resource types cover Slack, Jira, HipChat, Pull Requests, Terraform state, and hundreds of others. See the official resource type catalog.

    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):

    yaml
    CONCOURSE_ADD_LOCAL_USER: "admin:adminpass,alice:alicepass,bob:bobpass"

    Restart:

    bash
    sudo docker compose up -d concourse-web

    GitHub 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
    Grab the Client ID and Client Secret, then add to Compose:

    yaml
    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:

    yaml
    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):

    yaml
    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-stopped

    Bring it up:

    bash
    sudo docker compose up -d vault

    Load a few secrets:

    bash
    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 bar
  • concourse/main/foo → key bar
  • Configure the web node to talk to Vault by adding to the concourse-web environment:

    yaml
    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:

    yaml
    CONCOURSE_VAULT_AUTH_BACKEND: "approle"
    CONCOURSE_VAULT_AUTH_PARAM: "role_id:xxx,secret_id:yyy"

    Restart web:

    bash
    sudo docker compose up -d concourse-web

    Now ((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:

    bash
    sudo apt install -y nginx certbot python3-certbot-nginx

    Point your DNS A record (ci.yourdomain.com) at the VPS IP and wait for propagation.

    Create /etc/nginx/sites-available/concourse:

    nginx
    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:

    bash
    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 nginx

    Update the CONCOURSE_EXTERNAL_URL in Compose to your HTTPS URL and restart:

    bash
    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-web

    Log back in via the public URL:

    bash
    fly -t vps login -c https://ci.yourdomain.com -u admin

    The 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:

    bash
    fly -t vps set-team -n platform \
      --local-user alice \
      --github-org your-org:platform-team

    Log in to it:

    bash
    fly -t platform-vps login -c https://ci.yourdomain.com -n platform -u alice

    Push a pipeline that belongs to that team:

    bash
    fly -t platform-vps set-pipeline -p api -c ~/api-pipeline.yml

    Pipelines 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:

    bash
    fly -t vps set-team -n platform \
      --local-user alice \
      --github-org your-org:platform-team \
      --github-team your-org:platform-viewers=viewer

    viewer 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:

    yaml
    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:

    bash
    sudo docker compose up -d
    fly -t vps workers

    Path 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:

    bash
    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 worker

    The 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):

    bash
    -e CONCOURSE_TEAM=prod

    Worker tags

    Tag workers for specific workloads (GPU builds, macOS, etc.):

    bash
    -e CONCOURSE_TAG=gpu,cuda12

    Then in a pipeline task:

    yaml
    task: train
    tags: [gpu]

    Troubleshooting

    ProblemCauseSolution
    Worker stays stalled in fly workersWeb node cannot reach worker, or vice versaCheck docker compose logs concourse-worker; verify CONCOURSE_TSA_HOST is reachable; fly prune-worker -w NAME to evict.
    "no versions available" on get stepResource has not fetched yet, or check is failingfly check-resource -r pipeline/resource; inspect the check's logs in the UI.
    Builds fail with cannot allocate memoryWorker host out of RAMReduce concurrent builds, add swap, or scale workers across VPS nodes.
    fly sync mismatch warningCLI version older than serverfly -t vps sync to auto-update the binary.
    Secrets ((foo.bar)) not resolvingVault path or prefix wrongCheck CONCOURSE_VAULT_PATH_PREFIX and CONCOURSE_VAULT_LOOKUP_TEMPLATES; use fly get-pipeline -p foo to see unresolved refs.
    Websocket build logs stall behind NginxMissing Upgrade / Connection headersEnsure the Nginx config in Step 8 includes both and proxy_read_timeout 86400s.
    oci-build-task fails with "operation not permitted"Task missing privileged: trueAdd privileged: true at the task level; required for the BuildKit-based build task.
    Disk filling up on workerStale build containers and cached volumesfly -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

    bash
    # Hijack a failing task's container to poke around
    fly -t vps hijack -j my-app/test

    Watch a running build

    fly -t vps watch -j my-app/build-and-push

    Dump effective pipeline config (secrets unresolved)

    fly -t vps get-pipeline -p my-app

    Validate a pipeline file without pushing

    fly -t vps validate-pipeline -c ~/app-pipeline.yml

    FAQ

    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:

    bash
    sudo docker compose exec concourse-db pg_dump -U concourse concourse > /backups/concourse-$(date +%F).sql

    To 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-request resource 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-notification community 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:, and ensure: 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

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket