Part 5 of 6
    40 min

    Security & Authentication

    Secure your CI/CD platform with authentication, secrets management, TLS, and hardening best practices

    A CI/CD system has access to source code, deployment credentials, and production infrastructure—making security paramount. This guide covers securing your Concourse installation from authentication to secrets management.

    Authentication Methods

    Concourse supports multiple authentication backends. Choose based on your organization's needs.

    Local Users

    Simplest option for small teams or development:

    docker-compose.yml
    environment:
      CONCOURSE_ADD_LOCAL_USER: admin:${ADMIN_PASSWORD},dev:${DEV_PASSWORD},readonly:${RO_PASSWORD}
      CONCOURSE_MAIN_TEAM_LOCAL_USER: admin
    Binary installation
    concourse web \
      --add-local-user=admin:supersecret \
      --add-local-user=developer:devpass \
      --main-team-local-user=admin

    GitHub Authentication

    Integrate with GitHub organizations:

    docker-compose.yml
    environment:
      CONCOURSE_GITHUB_CLIENT_ID: ((github-client-id))
      CONCOURSE_GITHUB_CLIENT_SECRET: ((github-client-secret))

    Create a GitHub OAuth App:

    1. Go to GitHub → Settings → Developer settings → OAuth Apps
    2. Set Authorization callback URL: https://concourse.example.com/sky/issuer/callback
    3. Note the Client ID and Secret
    Configure team access
    # Allow GitHub org members
    fly -t main set-team -n main \
      --github-org=your-org
    
    # Allow specific team
    fly -t main set-team -n platform \
      --github-team=your-org:platform-team
    
    # Combine with local users
    fly -t main set-team -n main \
      --github-org=your-org \
      --local-user=emergency-admin

    GitLab Authentication

    docker-compose.yml
    environment:
      CONCOURSE_GITLAB_CLIENT_ID: ((gitlab-client-id))
      CONCOURSE_GITLAB_CLIENT_SECRET: ((gitlab-client-secret))
      CONCOURSE_GITLAB_HOST: https://gitlab.example.com  # For self-hosted

    LDAP Authentication

    For enterprise directory integration:

    docker-compose.yml
    environment:
      CONCOURSE_LDAP_HOST: ldap.example.com
      CONCOURSE_LDAP_PORT: 636
      CONCOURSE_LDAP_INSECURE_NO_SSL: "false"
      CONCOURSE_LDAP_START_TLS: "true"
      CONCOURSE_LDAP_BIND_DN: cn=concourse,ou=services,dc=example,dc=com
      CONCOURSE_LDAP_BIND_PW: ((ldap-bind-password))
      CONCOURSE_LDAP_USER_SEARCH_BASE_DN: ou=users,dc=example,dc=com
      CONCOURSE_LDAP_USER_SEARCH_USERNAME: sAMAccountName
      CONCOURSE_LDAP_USER_SEARCH_FILTER: (objectClass=person)
      CONCOURSE_LDAP_GROUP_SEARCH_BASE_DN: ou=groups,dc=example,dc=com
      CONCOURSE_LDAP_GROUP_SEARCH_GROUP_ATTR: cn
      CONCOURSE_LDAP_GROUP_SEARCH_USER_ATTR: DN
      CONCOURSE_LDAP_GROUP_SEARCH_FILTER: (objectClass=group)

    OIDC (OpenID Connect)

    Generic OIDC support for Okta, Auth0, Keycloak, etc.:

    docker-compose.yml
    environment:
      CONCOURSE_OIDC_DISPLAY_NAME: "Okta"
      CONCOURSE_OIDC_CLIENT_ID: ((oidc-client-id))
      CONCOURSE_OIDC_CLIENT_SECRET: ((oidc-client-secret))
      CONCOURSE_OIDC_ISSUER: https://your-org.okta.com
      CONCOURSE_OIDC_SCOPE: "openid profile email groups"
      CONCOURSE_OIDC_GROUPS_KEY: groups

    Team-Based Access Control

    Creating Teams

    Teams isolate pipelines and provide access boundaries:

    # Create team with GitHub auth
    fly -t main set-team -n frontend \
      --github-team=myorg:frontend-developers
    
    # Create team with mixed auth
    fly -t main set-team -n backend \
      --github-team=myorg:backend-team \
      --local-user=contractor1
    
    # List all teams
    fly -t main teams

    Role-Based Permissions

    Each team supports four roles:

    RolePermissions
    ownerFull control: manage team, destroy pipelines, manage secrets
    memberConfigure and trigger pipelines, view builds
    pipeline-operatorPause/unpause pipelines, trigger builds
    viewerRead-only access to pipelines and builds
    # Set team with specific roles
    fly -t main set-team -n production \
      --github-team=myorg:platform-team:owner \
      --github-team=myorg:developers:member \
      --github-team=myorg:qa-team:viewer

    Pipeline Visibility

    jobs:
    - name: deploy-production
      public: false  # Only team members can view
      plan:
      - get: source
      # ...
    
    - name: build
      public: true  # Anyone can view (but not trigger)
      plan:
      # ...

    Secrets Management

    ⚠️ Never store secrets in pipeline YAML. Use a credential manager.

    HashiCorp Vault Integration

    The most common enterprise choice:

    docker-compose.yml
    environment:
      CONCOURSE_VAULT_URL: https://vault.example.com
      CONCOURSE_VAULT_AUTH_BACKEND: approle
      CONCOURSE_VAULT_AUTH_PARAM: "role_id:xxx,secret_id:yyy"
      CONCOURSE_VAULT_PATH_PREFIX: /concourse
      CONCOURSE_VAULT_LOOKUP_TEMPLATES: "/concourse/{{.Team}}/{{.Pipeline}}/{{.Secret}},/concourse/{{.Team}}/{{.Secret}},/concourse/shared/{{.Secret}}"

    Store secrets in Vault:

    # Pipeline-specific secret
    vault kv put concourse/main/my-pipeline/docker-password value=supersecret
    
    # Team-wide secret
    vault kv put concourse/main/git-private-key value=@private-key.pem
    
    # Shared across all teams
    vault kv put concourse/shared/slack-webhook value=https://hooks.slack.com/...

    Reference in pipeline:

    resources:
    - name: docker-image
      type: registry-image
      source:
        repository: myorg/app
        username: ((docker-username))      # Looks up: concourse/main/my-pipeline/docker-username
        password: ((docker-password))      # Falls back to: concourse/main/docker-password

    AWS Secrets Manager

    environment:
      CONCOURSE_AWS_SECRETSMANAGER_REGION: us-east-1
      CONCOURSE_AWS_SECRETSMANAGER_PIPELINE_SECRET_TEMPLATE: /concourse/{{.Team}}/{{.Pipeline}}/{{.Secret}}
      CONCOURSE_AWS_SECRETSMANAGER_TEAM_SECRET_TEMPLATE: /concourse/{{.Team}}/{{.Secret}}

    Credential Lookup Order

    Concourse searches credentials in order:

    1. Pipeline-specific: /team/pipeline/secret
    2. Team-wide: /team/secret
    3. Shared: /shared/secret (if configured)

    This allows overriding shared credentials per-pipeline.

    TLS/HTTPS Configuration

    Always run Concourse behind HTTPS in production.

    Reverse Proxy with Nginx

    /etc/nginx/sites-available/concourse
    upstream concourse {
        server 127.0.0.1:8080;
    }
    
    server {
        listen 80;
        server_name concourse.example.com;
        return 301 https://$server_name$request_uri;
    }
    
    server {
        listen 443 ssl http2;
        server_name concourse.example.com;
    
        ssl_certificate /etc/letsencrypt/live/concourse.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/concourse.example.com/privkey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
        ssl_prefer_server_ciphers off;
    
        # WebSocket support for streaming logs
        location / {
            proxy_pass http://concourse;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            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;
            proxy_read_timeout 900s;
        }
    }

    Update Concourse external URL:

    environment:
      CONCOURSE_EXTERNAL_URL: https://concourse.example.com

    Direct TLS (Without Reverse Proxy)

    environment:
      CONCOURSE_TLS_BIND_PORT: 443
      CONCOURSE_TLS_CERT: /certs/server.crt
      CONCOURSE_TLS_KEY: /certs/server.key
      CONCOURSE_EXTERNAL_URL: https://concourse.example.com

    Network Security

    Firewall Configuration

    Minimize exposed ports:

    UFW example
    # Allow HTTPS (via reverse proxy)
    sudo ufw allow 443/tcp
    
    # Allow SSH (for management)
    sudo ufw allow 22/tcp
    
    # Block direct access to Concourse (if using reverse proxy)
    sudo ufw deny 8080/tcp
    
    # Allow TSA port only from worker IPs (if external workers)
    sudo ufw allow from 10.0.0.0/8 to any port 2222
    
    sudo ufw enable

    Worker Network Isolation

    For untrusted workloads, isolate workers:

    environment:
      # Limit worker network access
      CONCOURSE_GARDEN_DNS_SERVER: 10.0.0.53  # Internal DNS only
      CONCOURSE_GARDEN_DENY_NETWORK: 169.254.169.254/32  # Block metadata service
      CONCOURSE_GARDEN_DENY_NETWORK: 10.0.0.0/8  # Block internal network

    Database Security

    Secure PostgreSQL:

    Create dedicated user
    -- Create dedicated user with limited privileges
    CREATE USER concourse WITH PASSWORD 'strong_password_here';
    CREATE DATABASE concourse OWNER concourse;
    
    -- Revoke public schema access
    REVOKE ALL ON SCHEMA public FROM PUBLIC;
    GRANT ALL ON SCHEMA public TO concourse;
    Enable SSL
    environment:
      CONCOURSE_POSTGRES_SSLMODE: verify-full
      CONCOURSE_POSTGRES_CA_CERT: /certs/postgres-ca.crt

    Pipeline Security Best Practices

    Never Log Secrets

    - task: deploy
      config:
        run:
          path: sh
          args:
          - -c
          - |
            # BAD: This logs the secret!
            echo "Using password: $DB_PASSWORD"
            
            # GOOD: Use secrets without logging
            mysql -u app -p"$DB_PASSWORD" -e "SELECT 1"
      params:
        DB_PASSWORD: ((db-password))

    Limit Task Privileges

    Only use privileged: true when absolutely necessary:

    # Only for building Docker images
    - task: build-image
      privileged: true  # Required for Docker-in-Docker
      config:
        # ...
    
    # Normal tasks should NOT be privileged
    - task: run-tests
      # privileged: false (default)
      config:
        # ...

    Pin Image Versions

    Don't use latest in production pipelines:

    # BAD: Unpredictable
    image_resource:
      type: registry-image
      source:
        repository: node
        tag: latest
    
    # GOOD: Deterministic
    image_resource:
      type: registry-image
      source:
        repository: node
        tag: "20.10.0-alpine3.18"
        # Or use digest for maximum security:
        digest: sha256:abc123...

    Validate Pipeline Changes

    - name: validate-ci-changes
      plan:
      - get: ci-repo
        trigger: true
      
      - task: validate-pipeline
        config:
          platform: linux
          image_resource:
            type: registry-image
            source: { repository: concourse/concourse, tag: "7.11" }
          inputs:
          - name: ci-repo
          run:
            path: sh
            args:
            - -exc
            - |
              fly validate-pipeline -c ci-repo/ci/pipeline.yml

    Audit Logging

    Enable comprehensive logging for compliance:

    environment:
      # Log all API requests
      CONCOURSE_LOG_LEVEL: info
      CONCOURSE_LOG_DB_QUERIES: "true"
      
      # Structured logging for aggregation
      CONCOURSE_LOG_FORMAT: json

    Build Event Forwarding

    environment:
      # Emit events to external systems
      CONCOURSE_EMIT_TO_LOGS: "true"

    Integrate with log aggregation:

    docker-compose.yml with logging driver
    services:
      concourse-web:
        logging:
          driver: "fluentd"
          options:
            fluentd-address: "localhost:24224"
            tag: "concourse.web"

    Security Checklist

    Pre-Production Checklist

    • TLS/HTTPS enabled with valid certificates
    • Strong admin passwords (20+ characters, random)
    • Authentication backend configured (not just local users)
    • Credential manager integrated (Vault, AWS SM, etc.)
    • PostgreSQL SSL enabled
    • Firewall rules in place
    • Worker isolation configured
    • Audit logging enabled

    Ongoing Security

    • Regular credential rotation
    • Monitor failed login attempts
    • Review team memberships quarterly
    • Update Concourse regularly
    • Scan worker images for vulnerabilities
    • Review pipeline permissions

    Incident Response

    Revoking Access

    # Remove user from team immediately
    fly -t main set-team -n compromised-team \
      --github-team=myorg:safe-team
      # Omit the compromised user/group
    
    # Destroy compromised pipeline
    fly -t main destroy-pipeline -p compromised-pipeline
    
    # Rotate all secrets in the team's namespace
    vault kv metadata delete -mount=concourse main/compromised-pipeline

    Audit Recent Activity

    # List recent builds
    fly -t main builds
    
    # Check pipeline history
    fly -t main builds -p my-pipeline
    
    # View specific build logs
    fly -t main watch -j my-pipeline/my-job -b 42

    Next Steps

    Your Concourse installation is now secured! In Part 6, we'll optimize for production with scaling strategies, monitoring, and maintenance procedures.