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:
environment:
CONCOURSE_ADD_LOCAL_USER: admin:${ADMIN_PASSWORD},dev:${DEV_PASSWORD},readonly:${RO_PASSWORD}
CONCOURSE_MAIN_TEAM_LOCAL_USER: adminconcourse web \
--add-local-user=admin:supersecret \
--add-local-user=developer:devpass \
--main-team-local-user=adminGitHub Authentication
Integrate with GitHub organizations:
environment:
CONCOURSE_GITHUB_CLIENT_ID: ((github-client-id))
CONCOURSE_GITHUB_CLIENT_SECRET: ((github-client-secret))Create a GitHub OAuth App:
- Go to GitHub → Settings → Developer settings → OAuth Apps
- Set Authorization callback URL:
https://concourse.example.com/sky/issuer/callback - Note the Client ID and Secret
# 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-adminGitLab Authentication
environment:
CONCOURSE_GITLAB_CLIENT_ID: ((gitlab-client-id))
CONCOURSE_GITLAB_CLIENT_SECRET: ((gitlab-client-secret))
CONCOURSE_GITLAB_HOST: https://gitlab.example.com # For self-hostedLDAP Authentication
For enterprise directory integration:
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.:
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: groupsTeam-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 teamsRole-Based Permissions
Each team supports four roles:
| Role | Permissions |
|---|---|
| owner | Full control: manage team, destroy pipelines, manage secrets |
| member | Configure and trigger pipelines, view builds |
| pipeline-operator | Pause/unpause pipelines, trigger builds |
| viewer | Read-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:viewerPipeline 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:
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-passwordAWS 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:
- Pipeline-specific:
/team/pipeline/secret - Team-wide:
/team/secret - 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
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.comDirect 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.comNetwork Security
Firewall Configuration
Minimize exposed ports:
# 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 enableWorker 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 networkDatabase Security
Secure PostgreSQL:
-- 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;environment:
CONCOURSE_POSTGRES_SSLMODE: verify-full
CONCOURSE_POSTGRES_CA_CERT: /certs/postgres-ca.crtPipeline 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.ymlAudit 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: jsonBuild Event Forwarding
environment:
# Emit events to external systems
CONCOURSE_EMIT_TO_LOGS: "true"Integrate with log aggregation:
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-pipelineAudit 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 42Next Steps
Your Concourse installation is now secured! In Part 6, we'll optimize for production with scaling strategies, monitoring, and maintenance procedures.
