atlas: better local development
⚠️ Note: Atlas is in early preview, bugs and changes are expected.
background
Local development involves running as many services as possible locally, mostly using containers for reproducible builds
and isolated environments. While it's easy to configure basic setups using Docker Compose, a higher number of services
or more complex dependency relationships can quickly exceed its capabilities.
Atlas is built to handle complex local development environments, based largely on Docker containers. Each service is
defined in an Atlasfile, which is usually written as code to make configuration straightforward (reusing environment
variables or fetching data from external systems like IaC is just a few lines of code away), and composed in a stack.
Atlas simply detects the dependency graph, builds all required artifacts (Docker images for services), and starts the
necessary containers.
features
- Artifact Graph: All required artifacts are collected and built in the most efficient order, leveraging layer
caching and parallel builds.
- Atlasfiles: Atlasfiles can be written in Go, Node.js, TOML, and potentially any other language supporting gRPC.
- Services: Services are defined close to the relevant code, as code.
- Stacks: Stacks can define multiple services and overwrite configuration where needed
installation
homebrew
brew tap brunoscheufler/atlas
brew install brunoscheufler/atlas/atlas
binary
Download the Atlas CLI binary from the releases page.
getting started
creating a root Atlasfile
In the root directory of your repository, you can create a Atlasfile.root.go
file. This is structurally the same as
any other Atlasfile, but tells Atlas to stop looking for further Atlasfiles in parent directories. This is relevant,
because you can use the Atlas CLI in any layer of your project directory structure, and it will include all detected
Atlasfiles from the root file downwards.
package main
import (
"fmt"
"github.com/brunoscheufler/atlas/atlasfile"
"github.com/brunoscheufler/atlas/sdk/atlas-sdk-go"
"os"
)
func main() {
err := sdk.Start(&atlasfile.Atlasfile{
Services: []atlasfile.ServiceConfig{
{
Name: "global-db",
Image: "postgres:14",
Ports: []atlasfile.PortRequest{{5432, "tcp"}},
Environment: map[string]string{
"POSTGRES_USER": "directory",
"POSTGRES_PASSWORD": "directory",
"POSTGRES_DB": "directory",
"PGDATA": "/var/lib/postgresql/test-data",
},
Volumes: []atlasfile.VolumeConfig{
{IsVolume: true, HostPathOrVolumeName: "postgres", ContainerPath: "/var/lib/postgresql/test-data"},
},
},
{
Name: "localstack",
Image: "localstack/localstack:0.13.3",
Ports: []atlasfile.PortRequest{{4566, "tcp"}},
Environment: map[string]string{
"SERVICES": "s3,sqs,kinesis,firehose",
"DEFAULT_REGION": "eu-central-1",
"EDGE_PORT": "4566",
"HOSTNAME_EXTERNAL": "localstack",
},
Volumes: []atlasfile.VolumeConfig{
{HostPathOrVolumeName: "./localstack", ContainerPath: "/docker-entrypoint-initaws.d"},
},
},
},
})
if err != nil {
fmt.Printf("could not start atlasfile: %s", err.Error())
os.Exit(1)
}
}
adding a service
In the directory of your service, you can create another Atlasfile, outlining how your service should be built, which
ports it offers, and which environment variables (or env files) it receives by default.
package main
import (
"fmt"
"github.com/brunoscheufler/atlas/atlasfile"
"github.com/brunoscheufler/atlas/sdk/atlas-sdk-go"
"os"
)
func main() {
err := sdk.Start(&atlasfile.Atlasfile{
Services: []atlasfile.ServiceConfig{
{
Name: "api",
Artifact: &atlasfile.ArtifactRef{
Artifact: &atlasfile.ArtifactConfig{
Name: "api",
},
},
},
},
})
if err != nil {
fmt.Printf("could not start atlasfile: %s", err.Error())
os.Exit(1)
}
}
As you can see, defining your service can range from simple cases to more complex ones. You don't necessarily need to
build an image, you could also just pass an Image
right away. If you want to reuse the same base image for multiple
service entrypoints, just declare the artifact once and refer to it by name:
package main
import (
"fmt"
"github.com/brunoscheufler/atlas/atlasfile"
"github.com/brunoscheufler/atlas/sdk/atlas-sdk-go"
"os"
)
func main() {
err := sdk.Start(&atlasfile.Atlasfile{
Artifacts: []atlasfile.ArtifactConfig{
{
Name: "base",
},
},
Services: []atlasfile.ServiceConfig{
{
Name: "api",
Artifact: &atlasfile.ArtifactRef{
Name: "base",
},
Command: []string{"--server"},
EnvironmentFiles: []string{".env"},
},
{
Name: "worker",
Artifact: &atlasfile.ArtifactRef{
Name: "base",
},
Command: []string{"--worker"},
EnvironmentFiles: []string{".env"},
},
},
})
if err != nil {
fmt.Printf("could not start atlasfile: %s", err.Error())
os.Exit(1)
}
}
defining a stack
Last but not least, Atlas allows you to define groups of services that should be provisioned together, as part of a
stack.
package main
import (
"fmt"
"github.com/brunoscheufler/atlas/atlasfile"
"github.com/brunoscheufler/atlas/sdk/atlas-sdk-go"
"os"
)
func main() {
err := sdk.Start(&atlasfile.Atlasfile{
Stacks: []atlasfile.StackConfig{
{
Name: "regional",
Services: []atlasfile.StackService{
{
Name: "api",
},
{
Name: "worker",
},
},
},
},
})
if err != nil {
fmt.Printf("could not start atlasfile: %s", err.Error())
os.Exit(1)
}
}
launching the stack
Simply run
atlas up
Atlas will build all artifacts in order, after which all configured service containers are launched in dedicated Docker
networks.
reference
Atlasfile
basics
In each Atlasfile, you can define artifacts, services, and stacks. Since all Atlasfiles are read and merged at whenever
you use the Atlas CLI, there are no requirements as to how many Atlasfiles you should create, so you can simply find out
what works best for you.
artifacts
Artifacts generate OCI-compliant container images using docker build
. You can pass all relevant options like context,
dockerfile, and build args.
services
Services require an image or artifact to create a container from, and can be configured with environment variables,
environment files, ports, volumes, and commands.
stacks
Stacks assemble multiple services in a specific order, and can be started, stopped, and restarted together.
scope
Atlas is designed to improve the local development experience, and is not meant for other environments. The
features are designed to solve issues with local development, and may not fit well for other use cases, which is a
deliberate decision to keep the scope of the project small and focused.