Skip to content

Conversation

@jleal52
Copy link

@jleal52 jleal52 commented Oct 26, 2025

Add environment-based variable replacement for stacks

This PR implements environment-specific variable replacement for Docker Swarm stacks, allowing different configurations per environment (dev, staging, prod, etc.) stored directly in the stack's Git repository.

How it works

  1. Environment detection: SwarmCD reads the environment from a Docker Swarm manager node label (default: swarmcd.environment)

  2. Configuration file: Each stack can define an environments_file in stacks.yaml pointing to a YAML file in the repository with environment-specific variables

  3. Variable replacement: Variables in the format ${VAR_NAME} or $VAR_NAME in the compose file are replaced with values from the environments file

  4. Stack filtering: Stacks with an environments_file are only deployed if:

    • The node has an environment label set
    • The current environment is defined in the environments file

Example

stacks.yaml:

my-app:
  repo: my-repo
  branch: main
  compose_file: docker-compose.yml
  environments_file: environments.yml  # New field

environments.yml (in repository):

environments:
  prod:
    DB_HOST: prod-db.example.com
    API_URL: https://api.example.com
  dev:
    DB_HOST: dev-db.local
    API_URL: https://dev-api.example.com

docker-compose.yml (in repository):

services:
  app:
    environment:
      DATABASE_HOST: ${DB_HOST}
      API_ENDPOINT: ${API_URL}

Benefits

  • Environment-specific configuration versioned with the stack code
  • No need for multiple compose files per environment
  • Automatic stack filtering based on environment
  • Variables replaced recursively in all compose fields (environment, command, labels, etc.)

Backward compatibility

  • Fully backward compatible - stacks without environments_file deploy normally
  • No breaking changes to existing configurations

Files changed

  • util/config.go: Added EnvironmentsFile field to StackConfig
  • swarmcd/init.go: Added environment detection from Docker Swarm node labels
  • swarmcd/stack.go: Implemented variable loading and replacement logic
  • swarmcd/stack_test.go: Added comprehensive tests for the new functionality
  • docs/ENVIRONMENT_FILTERING.md: Complete documentation with examples
  • docs/stacks.yaml: Updated with environments_file example
  • docs/environments.yaml: Example environments file

Testing

All tests pass successfully (8/8):

  • Variable replacement in simple values
  • Variable replacement in nested structures (maps and arrays)
  • Environment filtering logic
  • Backward compatibility with stacks without environments file

Copy link
Collaborator

@sanzoghenzo sanzoghenzo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What a great feature!

It looks OK overall.
Some of the comments come from my personal preference, so they are open for debate 😉

I got two more observations (that can be addressed in future PRs):

  • docker compose also supports envrionment variable substitution by just specifying an empty item in the environment map (so you can avoid repeating the variable name in the value), should we support this, too?
  • Should we also support a global environments_file? I'm thinking, for instance, of a DOMAIN variable that could be used across all the stacks

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal preference, but this AI generated doc seems too verbose to describe a relatively simple concept.

Please also add a link to this document in the main readme file.

currentEnvironment = env
logger.Info("detected environment from node label", "environment", currentEnvironment, "label", environmentLabel)
} else {
logger.Warn("environment label not found on manager node, all stacks will be deployed", "label", environmentLabel)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel this should be a Warning, since this feature is an opt-in

Comment on lines +104 to +108
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("could not create docker client for environment detection: %w", err)
}
defer cli.Close()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to not use the dockerCli object here?


// Check if this is a swarm manager
if !info.Swarm.ControlAvailable {
return fmt.Errorf("this node is not a swarm manager, cannot detect environment")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The node in which SwarmCD runs could be different from the managed node if a user sets a remote url in DOCKER_HOST or uses a socket proxy, so the "this node" wording could cause a bit of confusion.

err = initDockerCli()
err = detectEnvironment()
if err != nil {
return err
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe an error of detectEnvironment should stop SwarmCD, especially if a user doesn't need this feature.

Comment on lines +61 to 106
shouldDeploy, err := swarmStack.loadEnvironmentVars()
if err != nil {
return
}
if !shouldDeploy {
log.Info("stack skipped due to environment filtering")
return revision, nil
}

stackBytes, err := swarmStack.readStack()
if err != nil {
return
}

if swarmStack.valuesFile != "" {
log.Debug("rendering template...")
stackBytes, err = swarmStack.renderComposeTemplate(stackBytes)
if err != nil {
return
}
}

stackContents, err := swarmStack.parseStackString([]byte(stackBytes))
if err != nil {
return
}

log.Debug("parsing stack content...")
stackContents, err := swarmStack.parseStackString([]byte(stackBytes))
err = swarmStack.replaceEnvVars(stackContents)
if err != nil {
return
}

log.Debug("decrypting secrets...")
err = swarmStack.decryptSopsFiles(stackContents)
if err != nil {
return "", fmt.Errorf("failed to decrypt one or more sops files for %s stack: %w", swarmStack.name, err)
}

log.Debug("rotating configs and secrets...")
err = swarmStack.rotateConfigsAndSecrets(stackContents)
if err != nil {
return
}

log.Debug("writing stack to file...")
err = swarmStack.writeStack(stackContents)
if err != nil {
return
}

log.Debug("deploying stack...")
err = swarmStack.deployStack()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to remove the debug messages? if you don't want to see them, just raise the log level 😉

Comment on lines +125 to +126
// If environments file is specified but no environment label is set on the node,
// this stack is environment-filtered and should NOT be deployed
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the log message explains this already, no need for a comment here

envFilePath := path.Join(swarmStack.repo.path, swarmStack.environmentsFile)
envFileBytes, err := os.ReadFile(envFilePath)
if err != nil {
// If file doesn't exist, it's not an error - just skip loading
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the comment

Comment on lines +166 to +167
// Current environment is not defined in the environments file
// This means this stack should not be deployed in this environment
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove comment

Comment on lines +171 to +182
"available_environments", getKeys(envConfig.Environments))
return false, nil
}
}

func getKeys(m map[string]map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a go guru, but it seems that this can be simplified with

Suggested change
"available_environments", getKeys(envConfig.Environments))
return false, nil
}
}
func getKeys(m map[string]map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
"available_environments", slices.Collect(maps.Keys(envConfig.Environments))
return false, nil
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants