# Atlas CMMS starts with a compose file that mounts two folders you are never told to create

> A self-hosted maintenance management system you run with three downloaded files and a Docker Compose command, with a prebuilt backend image, Postgres 16, MinIO and an nginx front. The example environment file ships a fixed JWT signing key, the variable table names a MinIO setting that appears in neither the example nor the compose file, and two bind-mounted directories are missing from the documented download list.

**Grashjs/cmms** — #1 Self hosted CMMS web & mobile application that allows you to manage enterprise maintenance for free - Computerized Maintenance Management System

- Repository: https://github.com/Grashjs/cmms
- Website: https://atlas-cmms.com
- Stars: 797 · Forks: 233
- Language: TypeScript
- License: AGPL-3.0
- Published: 2026-09-10 · Updated: 2026-09-10 · Language: en
- Canonical page: https://hysenlabs.com/projects/grashjs-cmms

## The compose file mounts a logo and config folder the quick start does not list

The whole setup is three files and one command:

```bash
docker-compose up -d
```

The three files are `docker-compose.yml`, `nginx.conf` and `.env.example`, the last of which you rename to `.env`. Then you open http://localhost:3000.

Now look at what the api service mounts:

```yaml
    volumes:
      - ./logo:/app/static/images
      - ./config:/app/static/config
```

There is no `logo` directory and no `config` directory in the repository root, which holds `.easignore`, `.env.example`, `.github/`, `.gitignore`, `.idea/`, `.vscode/`, `COMMERCIAL_LICENSE.MD`, `GCP-setup.md`, `LICENSE`, `README.MD`, `Security.md`, `api/`, `dev-docs/`, `docker-compose.yml`, `frontend/`, `home/`, `images/`, `mobile/`, `nginx.conf` and `scripts/`. Neither folder is in the three-file download list either.

So the documented first run asks Docker to bind mount two paths that do not exist. Docker creates the missing host paths as empty directories, so the container comes up and serves without an image or a config file, and the failure shows up later as a missing logo or a silently ignored setting rather than as a startup error. Creating both directories is a one line fix, and it is not in the instructions.

## The example environment file ships a fixed JWT signing key

The variable table is unusually honest about its own defaults, which is also the problem. Four entries are marked Required and all four ship a usable value: `POSTGRES_USER` defaults to `rootUser`, `POSTGRES_PWD` to `mypassword`, `MINIO_USER` to `minio`, and `MINIO_PWD` to `minio123`. The fifth required entry is the important one. `JWT_SECRET_KEY` is documented as the JWT secret key with the instruction to generate it with `openssl rand -base64 32`, and the documented default is the placeholder `your_jwt_secret`.

The example file does not use the placeholder. It carries a real base64 value:

```
JWT_SECRET_KEY=sD1HBM6ngcaDLMzDqgA9Pn9LEECNAp0C1EOHIR/D+q4=
```

That string is the first thing copied into a new deployment, because the file is named `.env.example` precisely so people copy it. The compose file then passes it straight through as `JWT_SECRET_KEY` to the backend, and it also passes through `PUBLIC_SERVER_URL` with no fallback at all, so an unset value arrives empty.

A signing key is the one secret whose compromise is silent. A predictable database password produces a connection error; a known JWT secret produces valid tokens for anyone who knows it, and nothing in the logs says so. The instruction to generate one is right there in the table. It just has to survive the copy.

## The variable table names a MinIO setting that exists in neither other file

Three files describe the same configuration and they do not agree on the names. The README table lists `MINIO_PWD` as required. The example environment file has no `MINIO_PWD`; it has `MINIO_PASSWORD=minio123`. The compose file reads `${MINIO_PASSWORD}` and hands it to the container as `MINIO_SECRET_KEY`.

So the only spelling that works is the one in the example file, and the documentation points at a name that appears nowhere else. Anyone configuring this from the table rather than from the example file gets an empty value and a container that cannot reach its object store.

The same class of drift appears in the storage switch. The compose file sets `STORAGE_TYPE: ${STORAGE_TYPE:-minio}` with the inline comment `#gpc|minio`, while the variables around it are named `GCP_BUCKET_NAME`, `GCP_JSON` and `GCP_PROJECT_ID`, and the example file sets `STORAGE_TYPE=MINIO` in capitals. The comment misspells the alternative backend. GPC appears nowhere else in the file, so a reader looking for how to switch to cloud storage is looking for the wrong acronym, and `GCP-setup.md` in the root is the only guide that uses the right one.

The database and bucket names are also hardcoded in the compose file while the credentials around them are variable: `POSTGRES_DB: atlas`, `DB_URL: postgres/atlas` and `MINIO_BUCKET: atlas-bucket` never come from the environment.

## Two license files and a license key the container checks at runtime

The repository carries both `LICENSE` and `COMMERCIAL_LICENSE.MD`, and the recorded license is AGPL-3.0. The compose file does not treat that as settled. It passes two variables into the backend:

```yaml
      LICENSE_KEY: ${LICENSE_KEY:-}
      LICENSE_FINGERPRINT_REQUIRED: ${LICENSE_FINGERPRINT_REQUIRED:-false
```

The second line is cut off in the file as published here, but the intent is legible from what is there: a license key defaults to empty, and a flag that can require a fingerprint defaults to false. So the open-source path is the default path, and the commercial path is enforced by the application rather than by anything in the repository structure.

That arrangement is common, and it raises the questions the two-file layout raises. Which terms apply to the deployed instance, what the fingerprint is computed from, and whether the commercial terms are compatible with the AGPL grant are all things the reader has to determine by reading `COMMERCIAL_LICENSE.MD` and the code rather than from the front page. The page says only that the system lets you manage enterprise maintenance for free.

The repository description opens with a ranking claim instead: #1 Self hosted CMMS. No measurement, comparison set or date is attached to it anywhere in the page.

## The documented path runs a prebuilt image, leaving frontend and mobile out of it

The api service is not built from this repository:

```yaml
  api:
    image: intelloop/atlas-cmms-backend
    container_name: atlas-cmms-backend
    depends_on:
      - postgres
      - minio
```

One published image, pulled under a namespace that is neither the repository owner nor the project name. So `docker-compose up -d` exercises a released backend, while `frontend/` and `mobile/` sit in the tree as source that the quick start never compiles. The repository's primary language is TypeScript, which is the frontend, and the backend identifies itself as a Spring application through the `SPRING_PROFILES_ACTIVE` variable and the `DB_URL`, `DB_USER` and `DB_PWD` names it expects.

Two more directories are worth naming. `mobile/` comes with a `.easignore` in the root, which is the ignore file for EAS builds, so the mobile client is a Flutter project built through that pipeline. `home/` is almost certainly the marketing site, and `api/` holds the document the page points at for the real feature list, a file named Current features.pdf, so the authoritative answer to what the product does is a PDF in a directory rather than text in the README.

There is also no `CONTRIBUTING.md`, on a page that says new contributors are wanted and asks readers to star the repository.

## The database is kept internal while the object store is published on the app's own URL

The Postgres service is configured the right way:

```yaml
  postgres:
    image: postgres:16-alpine
    container_name: atlas_db
    environment:
      POSTGRES_DB: atlas
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PWD}
    expose:
      - "5432"
```

`expose` rather than `ports` means the database port is declared to the compose network and not published to the host, so nothing on your machine can reach it directly. Given that the documented defaults are `rootUser` and `mypassword`, keeping the port closed is the second half of a decision whose first half is on the reader.

The object store is handled differently. The backend gets `MINIO_ENDPOINT: http://minio:9000`, which is the in-network address, and `PUBLIC_MINIO_ENDPOINT: ${PUBLIC_SERVER_URL}/storage`, which is the browser-facing address on the same origin and port as the application, under a `/storage` path. Those two values only make sense together if something routes that path, which is what `nginx.conf` is downloaded for. It is the one piece of the three-file set whose contents the page never describes.

Setting `STORAGE_TYPE` to the cloud option is the documented way to swap this out, and `GCP-setup.md` is where that path is written down.

## Eleven variables in the example file never reach the container

The example environment file is longer than the variable table, and the difference is where the surprise lives. The visible environment block of the api service forwards mail settings, the OAuth2 trio, the SSO flag, the GCP bucket variables and the MinIO pair. It does not forward `MAIL_TYPE`, `SENDGRID_API_KEY`, `SENDGRID_FROM_EMAIL`, `GOOGLE_KEY`, `GOOGLE_TRACKING_ID`, `ALLOWED_ORGANIZATION_ADMINS`, `LDAP_ENABLED`, `DEMO_LINK`, `CUSTOM_COLORS`, `BRAND_CONFIG` or `LOGO_PATHS`.

Read that list as capabilities rather than as omissions and a second product appears underneath the one described by the feature headings. Two sendgrid variables alongside a documented `MAIL_TYPE` switch of `SMTP` or `SENDGRID` means two mail transports configured in parallel. `LDAP_ENABLED` next to `ENABLE_SSO` and the OAuth2 variables means directory-backed authentication. `LOGO_PATHS`, `CUSTOM_COLORS` and `BRAND_CONFIG` are a white-labeling surface, which matches the bind mount of `./logo` into the container's static images. `DEMO_LINK` and `GOOGLE_KEY` with `GOOGLE_TRACKING_ID` point at a hosted demo and analytics.

None of that appears in the feature list, which covers work orders, analytics, equipment and inventory, user and workflow management, and locations and requests. Whether the application reads those values from the compose environment or from the mounted config directory is not something the three documented files settle, and that distinction decides whether setting them does anything.

## Conclusion

Atlas CMMS is worth an afternoon if you want a CMMS running on your own Postgres without a vendor, and the architecture is a sensible one. Four things to settle before you rely on it. The example environment file contains a fixed JWT signing key, so generate a real one before any deployment that faces a login form, and change the Postgres and MinIO credentials that are documented as `mypassword` and `minio123`. The bind mounts for `logo` and `config` are not in the download list, so create them or the container comes up with empty ones. The license story has two files and a runtime key check that defaults to off, so read both before you assume AGPL covers your use. And note that the documented path runs a prebuilt backend image, so building `frontend/` or `mobile/` from this repository is a separate exercise the quick start does not cover.

## FAQ

### How do I self-host Atlas CMMS?

Download docker-compose.yml, nginx.conf and .env.example, rename the last one to .env, then run `docker-compose up -d` and open http://localhost:3000. The api service runs a prebuilt image, intelloop/atlas-cmms-backend, against a postgres:16-alpine service and a MinIO service.

### What default credentials does the Atlas CMMS example environment use?

The example file sets POSTGRES_USER to rootUser, POSTGRES_PWD to mypassword, MINIO_USER to minio and MINIO_PASSWORD to minio123, and all four are marked Required in the variable table. The Postgres service uses expose rather than ports, so the database port is not published to the host.

### Does the Atlas CMMS JWT secret need to be changed?

The variable table documents JWT_SECRET_KEY as required and tells you to generate one with `openssl rand -base64 32`, yet the example file ships a fixed base64 value rather than the documented `your_jwt_secret` placeholder. Since that file is meant to be copied, the key should be regenerated before any deployment with a login form.

### Which file name is correct for the Atlas CMMS MinIO password variable?

MINIO_PASSWORD, as used by the example environment file and read by the compose file into MINIO_SECRET_KEY. The variable table documents it as MINIO_PWD, which appears in neither of the other two files.

### What license does Atlas CMMS use?

The recorded license is AGPL-3.0 and the root carries both LICENSE and COMMERCIAL_LICENSE.MD. The compose file also passes a LICENSE_KEY and a LICENSE_FINGERPRINT_REQUIRED flag that defaults to false, so a commercial check can be switched on inside the container and which terms apply is something to read rather than assume.

## Sources

- [Grashjs/cmms on GitHub](https://github.com/Grashjs/cmms)
- [License: AGPL-3.0](https://github.com/Grashjs/cmms/blob/main/LICENSE)
- [Project website](https://atlas-cmms.com)
- [README](https://github.com/Grashjs/cmms/blob/main/README.md)
- [Releases](https://github.com/Grashjs/cmms/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/grashjs-cmms
