Docker Development Environment - hackforla/tdm-calculator GitHub Wiki

Docker Development Environment

For development purposes, you may want to run the web api server as a docker container with NODE_ENV="development". This allows you to modify node/express code and have the container watch for changes to files and automatically restart the express server using nodemon. This gives you a development experience similar to what you would have if you just ran express on your native machine.

The main advantage of doing development with docker hosting the express server is that the environment will exactly replicate the production environment as far as node and npm versions and versions of all the npm packages.

This uses the docker-compose.yml docker compose script, which, in turn, uses the Dockerfile.dev build instructions to build an image that is suitable for running during development. A production build uses a slimmed-down image defined in Dockerfile and explained in the Deployment section.

Setting up the development environment for external developers

For external developers, this is the best way to get everything running.

Building a dev version of the web api server

  1. Copy the .env.example file

    cp .env.example .env
    
  2. Build and run the dev version of the web api server, database, and mock sendgrid email service

    docker-compose up -d
    # add --build flag to rebuild the image: docker-compose up --build
    # remove -d flag to start in foreground mode: docker-compose up
    

    The frontend is exposed at http://localhost:3001

  3. Sendgrid mock service

    • User registration will send an email to the mock sendgrid service, which can be found at http://localhost:7000 by default.
    • Choose the html view to see the link being sent.
  4. Stop the development environment

    docker-compose down
    # add -v flag to remove the volumes: docker-compose down -v
    

Setting up the development environment for TDM developers

There are two ways to set up the Docker development environment for TDM developers:

  1. Use docker to run the web client and api server against the shared development database on Azure.
  2. Use Docker to run the entire stack locally.

Development .env file

For TDM team members, use the dotenv file from gDrive in place of .env.example

  1. Copy the dotenv file from gDrive into the project root

    # Rename it as .env in the project root
    mv dotenv .env
    

Configure Docker settings

Using docker for client and api server only

  1. Open .env and uncomment the Database, Client, and External URLs sections under Docker settings overrides
    • note that all the containers will still run to satisfy the dependency checks, but they will not be used.

Using Docker for the entire stack

  1. Open .env and uncomment everything under Docker settings overrides

Run the development environment

  1. Run docker-compose up

    docker-compose up -d
    # add --build flag to rebuild the image: docker-compose up --build
    # remove -d flag to start in foreground mode: docker-compose up
    

    The frontend is exposed at http://localhost:3001

  2. Sendgrid mock service

    • User registration will send an email to the mock sendgrid service, which can be found at http://localhost:7000 by default.
    • Choose the html view to see the link being sent.

Stop the development environment

  1. Run docker-compose down

    docker-compose down
    # add -v flag to remove the volumes: docker-compose down -v
    

docker-compose.yml description

  1. Contains 6 services to run the software

    • client
    • api
    • db-migrate
    • db-init
    • db
    • sendgrid
  2. One volume to persist database data

    volumes:
        sqlvolume:
    
  3. Reads .env file from the same directory

    • All variables used in docker-compose.yml are defined in .env.
    • The env_file option in the services specifies the .env file for each service.
  4. Client service

    client:
        build:
            context: ./client
            dockerfile: Dockerfile.dev
        ports:
            - "$CLIENT_EXPOSED_PORT:$CLIENT_PORT"
        env_file:
            - .env
        volumes:
            - ./client/:/usr/app
        depends_on:
            api:
                condition: service_healthy
                restart: true
        healthcheck:
            test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:$CLIENT_PORT || exit 1"]
            interval: 10s
            retries: 10
            start_period: 10s
            timeout: 3s
    
    1. Builds Dockerfile.dev image from /client directory
    2. Runs on port CLIENT_EXPOSED_PORT
    3. The container uses environment variables from .env file
    4. Mounts /client directory to /usr/app
    5. Depends on api service to be healthy
    6. Runs healthcheck every 10 seconds
      • checks that http://localhost:$CLIENT_PORT is reachable
  5. API service (server)

    api:
        build:
        context: ./server
        dockerfile: Dockerfile.dev
        # uncomment to debug on localhost
        # ports:
        #   - "5002:$PORT"
        env_file:
            - .env
        volumes:
            - ./server/:/usr/app
        depends_on:
            db-migrate:
                condition: service_completed_successfully
            db:
                condition: service_healthy
                restart: true
        sendgrid:
            condition: service_started
        healthcheck:
            test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:$PORT/api/calculations || exit 1"]
            interval: 10s
            retries: 10
            start_period: 10s
            timeout: 3s
    
    1. Builds Dockerfile.dev image from /server directory
    2. Runs on port 5002 if uncommented.
      • There doesn't seem to be a good reason to do this.
    3. The container uses environment variables from .env file
    4. Mounts /server directory to /usr/app
    5. Depends on db service to be healthy
    6. Depends on db-migrate service to be completed successfully
    7. Depends on sendgrid service to be started
    8. Runs healthcheck every 10 seconds
      • checks that http://localhost:$PORT/api/calculations is reachable
  6. Database migration service

    db-migrate:
        image: redgate/flyway
        volumes:
            - ./server/db/migration:/flyway/sql
        depends_on:
        db-init:
            condition: service_completed_successfully
        db:
            condition: service_healthy
            restart: true
        command: [
            "-user=${SQL_USER_NAME:?error}",
            "-password=${SQL_PASSWORD:?error}",
            "-url=jdbc:sqlserver://${SQL_SERVER_NAME:?error}:${SQL_SERVER_PORT:?error};databaseName=${SQL_DATABASE_NAME:?error};trustServerCertificate=true",
            # uncomment for debug messages
            # "-X",
            "migrate",
        ]
    
    1. Uses the same image as the db service.
    2. Mounts /server/db/migration directory to /flyway/sql, where flyway will look for migration scripts
    3. Depends on db-init service to be completed successfully
    4. Depends on db service to be healthy
    5. Runs flyway migrate command to apply database migrations.
  7. Database initialization service

    db-init:
        image: "mcr.microsoft.com/mssql/server:2019-latest"
        volumes:
            - ./server/db:/db
        depends_on:
            db:
                condition: service_healthy
                restart: true
        entrypoint:
        [
            "/bin/sh",
            "-c",
            '/opt/mssql-tools18/bin/sqlcmd -C -S ${SQL_SERVER_NAME:?error} -U ${SQL_USER_NAME:?error} -P ${SQL_PASSWORD:?error} -Q "IF NOT EXISTS(SELECT * FROM sys.databases WHERE name = ''tdmdev'') CREATE DATABASE tdmdev; ELSE PRINT ''Database \"${SQL_DATABASE_NAME:?error}\" already exists. Operation successful.''" -b',
        ]
    
    1. Uses the same image as the db service.
    2. Mounts /server/db directory to /db.
    3. Depends on db service to be healthy
    4. Runs sqlcmd command to create the database
  8. Database

    db:
        image: "mcr.microsoft.com/mssql/server:2019-latest"
        volumes:
            - sqlvolume:/var/opt/mssql
        ports:
            # exposed on localhost 1434
            - "1434:1433"
        environment:
            ACCEPT_EULA: Y
            SA_PASSWORD: Dogfood1!
            MSSQL_PID: Express
        healthcheck:
            test:
                [
                "CMD-SHELL",
                "/opt/mssql-tools18/bin/sqlcmd -C -S ${DOCKER_DB_SERVER_NAME:?error} -U ${DOCKER_DB_USER_NAME:?error} -P ${DOCKER_DB_PASSWORD:?error} -Q 'SELECT 1' -b",
                ]
            interval: 10s
            retries: 10
            start_period: 10s
            timeout: 3s
    
    1. Uses SQL Server 2019 image
    2. Mounts volume sqlvolume to /var/opt/mssql
    3. Exposed on port 1434 for external access from DBeaver.
    4. Sets environment variables for the container.
    5. Runs healthcheck
      • Checks that sqlcmd command can connect to the database
  9. Sendgrid

    sendgrid:
        image: ghashange/sendgrid-mock:1.12.0
        ports:
            - "${SENDGRID_EXPOSED_PORT:-7000}:3000"
        environment:
            API_KEY: ${SENDGRID_API_KEY:?error}
    
    1. Uses ghashange/sendgrid-mock image
    2. Exposed on port 7000 unless SENDGRID_EXPOSED_PORT is set
    3. Sets API_KEY environment variable
      • The API_KEY used in the API server needs to match this to authenticate with sendgrid, and it does, because they're using the same environment variable.

Helpful tools/commands

  1. LazyDocker tool to manage docker containers in the terminal

  2. Dive utility to inspect docker images

  3. Inspect docker-compose config

    • It's helpful for looking at the docker-compose file after the enivronment variables have been expanded.
    docker-compose config
    
  4. Inspect docker-compose logs

    docker-compose logs
    
  5. Debug docker builds

    # Build the api image
    docker-compose build api --no-cache --progress=plain
    
    • Use the --no-cache option to build the image without using the cache.
    • Use the --progress=plain option to avoid the progress bar.

    See this page about debugging docker builds.

  6. Start a shell in a running container

    # Start a shell in the running api container.
    docker-compose exec api sh
    # The alpine image doesn't have bash, so we need to use the `sh` command.
    
    # Start a shell in using the docker image for the api container.
    docker-compose run --rm api sh
    # `--rm` removes the container after the shell is closed.
    

Resources

Decisions made

  1. Used node lts-alpine base image for the client and api server.

    • This is the same as in the production Dockerfile.
    • The volta section in package.json has node 20, which also works.
  2. Used SQL Server 2019 base image for the database.

    • This is from the wiki instructions, although I've also seen 2017 elsewhere.
    • We might want to move to SQL Server 2022 in the future, but it depends on what the version the production database server is. It's straightforward to change.
  3. Used redgate/flyway rather than node-flywaydb or node-flyway.

    • The good:
      1. It has a docker image option for ease of use with docker-compose.
      2. It's the underlying application which the node packages use.
    • The bad:
      1. redgate/flyway is not callable from node like the other options.
      2. Our currently used node-flywaydb's repo was archived on 2024-01-20, meaning it's considered outdated.
      3. node-flyway is maintained but doesn't yet have documentation on cli usage.
    • The bottomline:
      1. It's the best choice for docker usage.
        • The other options are either outdated or doesn't support cli.
        • We really just want to call it from the cli anyway, even though we have a node app.
  4. Added sendgrid service

    • The good:
      1. This enables running sendgrid email tests locally.
        • No need to connect to the network for development
    • The bottomline:
      1. Everything works locally inside docker containers.
  5. Added db-init service

    • The good:
      1. This enables the database to be created if it doesn't exist.
        • It saves a manual step.
      2. It uses the same database image as the db.
        • It saves space and contains all the db utilities.
    • The bad:
      1. This is why we need to move the .env file to the root directory.
        • The command being run makes use of the server environment variables for the database connection.
    • The bottomline:
      1. It saves a manual step.
        • We can simplify the setup documentation.
  6. Added db-migrate service

    • The good:
      1. This enables running database migrations.
        • It saves a manual step.
      2. It uses the same database image as the db.
        • It saves space and contains all the db utilities.
    • The bad:
      1. This is why we need to move the .env file to the root directory.
        • The command being run makes use of the server environment variables for the database connection.
    • The bottomline:
      1. It saves a manual step.
        • We can simplify the setup documentation.
  7. Use a docker volume for the database data.

    • The good:
      1. This makes the database persist between container restarts.
        • The database server can be upgraded without losing data, because the data is stored in a volume.
    • The bottomline:
      1. It lets us test database server upgrades more easily.
  8. External port number mappings different from the defaults.

    • The good:
      1. It lets us use/test different services from outside the containers.
      2. It allows us to potentially run the local development environment and the docker containers side-by-side.
        • These are value convenient to me, but they can be changed.
          1. client is exposed on port CLIENT_EXPORTED_PORT => 3001
            • We need this to use the client from a web browser.
          2. api is not exposed since I can't think of a use case for it
            • If it ever becomes necessary, I would suggest port 5002 since the local server port is 5001
          3. db is exposed on port DB_EXPORTED_PORT => 1434
            • An external client can connect to the database server.
          4. sendgrid is exposed on port SENDGRID_EXPORTED_PORT => 7000
            • This is from the example I used and for no other reason.
    • The bottomline:
      1. It keeps the existing local development environment setup working and allows adding the docker containers running side-by-side.
  9. Use docker cache mount to reduce rebuild time.

    • The good:
      1. This makes the rebuilds faster.
        • It caches the downloaded data, so it doesn't have to be downloaded again.
    • The bottomline:
      1. It makes the rebuilds faster.
  10. Convert vite.config.js to read environment variables.

    • The good:
      1. This makes it possible to run vite with in different environments.
        • The old values are converted into defaults.
    • The bottomline:
      1. This is necessary to make it run in both the local host and in docker.
  11. Make .env.example default to the docker environment.

    The file is moved from server/.env.example to ./.env.example

    • The good:
      1. This makes it easier to run in docker.
        • It doesn't have the values needed to run locally anyway.
    • The bottomline:
      1. The docker environment becomes a 1-step setup for external developers.
  12. Add a commented out "Docker" section to the gDrive dotenv file

    • The good:
      1. This maintains a non-breaking change.
        • It keeps the local development environment settings as the default while having the docker settings as an option.
        • Developers can uncomment the section to run just the app or all in docker.
    • Future work:
      1. The docker option should be the default if it works well.
    • The bottomline:
      1. This adds docker as an option to TDM developers.
  13. There's 6 containers from 5 images.

    • The good:
      1. This is how a docker environment should work.
        • Each service runs in its own container.
    • The bad:
      1. The images take up a lot of space: ~3.5GB total.
        • SQL Server 2019 = 1.49GB
        • flyway = 1.09GB
        • sendgrid-mock = 214MB
        • api = 279MB (base node:lts-alpine image is ~130MB)
        • client = 461MB (base node:lts-alpine image is ~130MB)
    • Future work:
      1. Can add an adminer container for database management.
        • It moves the functionality into the docker environment, where we currently have to install DBeaver or another client separately.
        • It's low priority but an option.
    • The bottomline:
      1. The docker environment becomes a 1-step setup for external developers.
  14. Replaced dotenv with env-cmd.

    • The good:
      1. It moves the environment variable loading logic from the application to the command line.
        • Just pass the .env file path to env-cmd before starting the application
      2. It allows us tomove the .env file from the client and server directories into the project root directory.
        • This lets docker-compose use the same .env file as the containers do. It eliminates repeating the same variables and values in multiple places. For example: PORT is used by both docker-compose and the api container.
    • Future work:
      1. dotenvx is another possibility that is similar to env-cmd but made by the dotenv author and has more features. The documentation is very good.
    • The bottomline:
      1. It doesn't affect the production build.
        • The production environment variables are loaded separately.
  15. Moved (root)/server/.env.example to (root)/.env.example

    • The good:
      1. This is to simplify the docker-compose call from the command line.
        • docker-compose up vs. docker-compose -e .env -e client/.env -e server/.env up
        • docker-compose by default looks for .env in the current directory. If it uses values in client and server .env files, it will have to load those files using the -e option.
      2. This reduces having redundant env variables at different levels.
        • The client and server both use the same PORT env variable for the server port.
        • Docker-compose and the server both use the same SQL_SERVER_NAME env variable.
    • The bad:
      1. It makes both projects use the same .env file.
        • There's env info leakage, but that's probably okay for local development. Production doesn't use .env files.
      2. It makes the client and server projects depend on being under the parent directory.
        • This is easy to change back later if nessary, by taking away the ../ from ../.env in the client and server package.json files.
    • The bottomline:
      1. It doesn't affect the production build.
        • The production environment variables are loaded separately.
  16. Added docker-compose service dependencies.

    • The good:
      1. This imposes the correct startup order
        • It makes sure that the database server is responsive, the database is created, and the migrations are run before the server starts.
        • The ordering is db => db-init => db-migrate => api => client.
        • Also sendgrid => api.
    • The bad:
      1. This is why we need to move the .env file to the root directory.
        • The db healthcheck makes use of the server environment variables for the database connection.
        • The client healthcheck command makes use of the client environment variables for the client connection.
        • The api healthcheck command makes use of the server environment variables for the api endpoint connection.
    • The bottomline:
      1. It makes containers always run in the same order.