Containers & Multi-Service Development
Learning Objectives
- You can distinguish an image, container, service, network address, and volume.
- You can explain the practice application’s startup dependencies and isolated Compose test project.
- You can identify when a source edit, restart, or image rebuild is appropriate.
In the previous chapter, we used Compose to start the application. The compose.yaml file and the API and client Dockerfiles explain how that command starts several connected services. There are quite a few settings here; connect each one to the application you have already run.
Image, container, and service
When Compose starts our database, it creates a container from a PostgreSQL image. The image packages a filesystem and runtime configuration; the container is an instance created from it. The Compose service describes the database’s role in our application and how its container should run.
The api and client services instead use images built from their respective Dockerfiles. These service names let us refer to the parts of our application while Compose runs them together on our computer.
Containers isolate processes and resources. Linux containers share the Linux kernel of the environment running them, rather than each booting an entire operating system. On systems using Docker Desktop, a virtual machine may supply that environment. You can use the course setup without learning these implementation details yet.
A document-preview deployment
0 / 5 points
A document-preview project builds an image named
preview-worker:local. Its Compose file declares a service named
previewer, and Compose creates a running container named
docs-previewer-1.
Select every accurate statement.
Building an image and editing source files
Start with the client image. Find these selected instructions in client/Dockerfile; they are excerpts, so keep the complete supplied file in place:
FROM denoland/deno:2.9.6WORKDIR /appCOPY deno.lock package.json ./RUN deno install --frozenCOPY tsconfig.json vite.config.ts vitest.setup.ts ./COPY src ./srcRUN deno task prepareCMD ["deno", "task", "dev"]| Instruction | Meaning here |
|---|---|
FROM | Start with the pinned image that supplies Deno and its operating-system files. |
WORKDIR /app | Set the working directory inside the image; this is not your host project’s path. |
COPY | Copy files from the build context into the image. Here ./ destinations are relative to /app. |
RUN | Execute a command while building. Install the locked dependencies, then run prepare to synchronize SvelteKit’s generated setup. |
CMD | Record the default command to execute when a container starts. It does not start the development server during the build. |
The corresponding settings in compose.yaml include:
services: client: build: ./client environment: VITE_API_BASE_URL: http://localhost:8000 volumes: - ./client/src:/app/src ports: - "5173:5173"The build: ./client setting tells Docker where to find files during the build. This directory is called the build context, so COPY src ./src reads client/src on the host and copies it into the image.
Once the container is running, the volumes setting makes the host’s client/src directory available at /app/src. This is a bind mount: the running container sees the host files at that path, covering the source copied into the image.
This is how a source edit reaches the running client. Editing client/src/lib/Counter.svelte changes the file Vite sees at /app/src/lib/Counter.svelte, without rebuilding the image. The command deno task dev runs the dev script in client/package.json, which synchronizes SvelteKit and starts Vite’s development server.
The API follows the same idea. Its source mount makes the whole api/ directory available read-only at /app. Its dev task in api/deno.json uses Deno’s watcher to restart the API process after source edits.
A normal source edit therefore does not require manually restarting the API or rebuilding the client image. Read the service logs to confirm that the watcher noticed a change. If the process needs a manual restart, docker compose restart api is available, but restarting does not install a new dependency.
Dependency and copied client-configuration changes require an image rebuild:
docker compose up -d --build api clientThe API uses cached dependencies at runtime. Changing an import to an uncached package requires updating the dependency declarations and lockfile and rebuilding; a mounted source file does not supply that package by itself. For Part 1, we will work with the supplied packages so we can concentrate on the application’s behavior.
Before moving on, choose a source file and find the mount that exposes it to its service. Then find a client configuration file that is copied during the build. Can you explain why changing these two files needs different actions? Docker’s bind-mount documentation explains the distinction further.
Development lifecycle changes
0 / 5 points
In an exercise starter, client source is bind-mounted into a watching Vite process. API source is also bind-mounted and the development API runs with Deno watch mode. The isolated Compose test project overrides that command with a non-watch start command. Dependency declarations and client framework configuration are copied during image construction.
Select every appropriate change-and-action pairing.
Separate dependencies, shared task shortcuts
You will find more than one dependency file in the practice application because each service has its own tools. The API declares dependencies and tasks in api/deno.json. The client uses client/package.json; Deno installs its npm dependencies and runs its scripts. The Playwright runner has its own e2e-tests/package.json and uses npm inside its image.
The root deno.json provides optional Docker command shortcuts. It is not a Deno workspace and does not combine the services’ dependencies. Docker-only commands remain available, so host Deno is optional.
Host addresses and container addresses
Think back to the addresses we opened in the browser. The host browser reaches the client through the published port localhost:5173, and browser JavaScript reaches the API through localhost:8000. The API makes its database connection from inside Docker’s network, where it reaches PostgreSQL using database:5432.
In "5173:5173", the left number is the host port and the right number is the container port. The client’s dev script uses --host 0.0.0.0 so Vite listens on the container’s network interfaces; publishing a port then makes that listener reachable from the host. 0.0.0.0 is a listening address, not the browser URL.
A mapping such as "8001:8000" would change the API’s host port while its listener remains on 8000. The browser’s API-base configuration would also need to use the new host port. Figure 1 shows which addresses the browser and API use in the supplied configuration.
localhost always refers to the environment making the connection. Inside the API container it refers to the API container, not the database or host browser. Compose service names are resolved on the Compose network; a host browser does not automatically know the name database.
The same distinction matters for end-to-end tests: their browser runs in a container, so the test environment uses client and api addresses inside the isolated Compose test project. This is why development and test API URLs differ. See Docker’s Compose networking guide.
Four callers and destinations
0 / 5 points
A document-preview project has these connections:
preview-clientpublishes container port 5173 as host port 7410;preview-apilistens on container port 8080 and publishes it as host port 7420;archive-dblistens on port 5432 only on the Compose network; andpreviewerandpreview-apishare that network.
Select every address that matches the stated caller and destination.
Development and test Compose files
An ordinary docker compose up reads compose.yaml and starts database, the one-shot database-migrations job, api, and client. Tests reuse these service definitions with an additional file, compose.test.yaml, and a distinct Compose project name.
The override adapts the application for tests. It replaces the database volume with temporary memory-backed storage and removes the API and client host-port publications. It also selects the API’s non-watch start command, sets internal browser/API addresses, and adds e2e-tests.
We also give the test application a different Compose project name. This separates its containers, networks, and volumes from development, while the override file changes how its services run. The practice application uses that combination rather than Compose test profiles. Adding the override to the development Compose project could reconfigure its services, so always provide both files and a distinct -p test name as shown in Chapter 1.8.
| Check | One-off runner | Supporting services |
|---|---|---|
| Deno API suite | api deno task test | database and database-migrations |
| Vitest component suite | client deno task test with --no-deps | None |
| Playwright browser suite | e2e-tests | Migrated database, API, and client |
A separate Compose test project can run alongside development because it publishes no competing host ports. Suites within one Compose test project still share its stored state; run them sequentially. We will use the complete commands and cleanup in Chapter 1.8. For now, the key idea is that tests get their own running application, separate from the one you are exploring in the browser.
Development and Compose test state
0 / 5 points
A room-booking project uses compose.yaml for development and adds
compose.test.yaml with a separate Compose project name for tests:
- development runs
client,api, anddatabase, with a named database volume; - the Compose test project reuses those service names, removes published ports, and replaces database storage with tmpfs; and
- API tests and the
e2e-testsbrowser runner use the Compose test project’s database. The component runner needs no API or database.
Within the test network, client listens on port 5173 and api
listens on port 8000.
Database-backed commands always include both Compose files and the same separate Compose test project name. The API test command calls the Hono application directly and starts only its database and migration dependencies.
Select every accurate statement about their boundaries and state.
Startup dependencies and health
You may have noticed that the services take turns starting. A database process, for example, needs time to become ready for requests. The database health check verifies readiness before dbmate runs. The API then waits for successful migration completion, and the client waits for the API health check.
Compose expresses those expectations through service_healthy and service_completed_successfully. Each check tells us about the behavior it checks: the API’s /health response shows that the health handler responds, while database queries need their own checks. Docker documents these startup conditions.
Figure 2 follows those conditions from the database to the client. Each arrow tells us what must happen before the next service starts.
Compare Figure 2 with the depends_on entries in compose.yaml. The migration job completes, while the API and client continue serving requests.
An API waiting at startup
0 / 5 points
A Compose setup requires the database to become healthy before its one-shot migration job starts. The API requires that job to complete successfully. You inspect this status:
database running (healthy)migrations exited (1)api waiting for its dependencyWhich explanation and next step fit this status?
Persistent and mounted data
The todo we loaded in the previous chapter belongs to the database, so we want it to survive ordinary container replacement. The practice application keeps the database in the named volume database-data, mounted at /var/lib/postgresql for PostgreSQL 18. This serves a different purpose from a source bind mount, which exposes files from your working directory.
The test database instead uses temporary memory-backed storage, separate from the development volume. Its data can remain while the test database container keeps running, so tests still need to arrange the starting data for their assertions. Chapter 1.8 shows how to prepare the environment for a test run.
Shared preview files
0 / 5 points
A document-preview application has a worker that creates previews and an API that reads them. Generated preview bytes must survive replacement of the worker container and must be readable by both the worker and API containers, without rebuilding application images when a preview is created. Which storage arrangement directly satisfies both requirements?
Configuration and secrets
The API needs a database address and credentials before it can make a connection. The env_file: project.env entries supply database variables to PostgreSQL, dbmate, and the API. Figure 3 follows the API’s connection setting from the file to a database query.
Deno.env.get reads the process environment; it does not open project.env itself. The import postgres resolves through "postgres": "npm:postgres@3.4.9" in api/deno.json. Postgres.js uses the URL’s host, port, database name, and credentials when a query needs a connection.
The variables serve two different purposes:
| Variables | Consumer and purpose |
|---|---|
POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB | The PostgreSQL image initializes its user and database when its data directory is empty. Changing these values does not rewrite an already initialized database. |
DATABASE_URL | The API and dbmate connect to that database. The supplied URL names database:5432/walking_skeleton and includes the matching coursework credentials. |
A service’s environment is distinct from Compose’s own interpolation of YAML settings such as the Compose project name. These supplied values are disposable coursework configuration, so no separate secret file is needed.
The client receives its public VITE_API_BASE_URL through its service configuration. It is not given the database connection string. A public API address belongs in browser configuration; a database password does not.
Inspect without exposing credentials
docker compose config --servicesdocker compose ps --alldocker compose logs --tail=30 apiThe first command lists service names and the others report runtime state. Together, they let you connect the configuration we have read to the services running on your computer. The full docker compose config output can contain resolved credentials; do not paste it into a public question without reviewing it.
Check Your Understanding
- What is the difference between the PostgreSQL image, its container, and the
databaseservice in Compose? - Why can a source edit appear without rebuilding an image, while a dependency change can require a rebuild?
- Which database address does the API container use, and why would
localhostrefer to something different there? - What must finish before the API starts, and what does its health check establish?
- How do the test override file and separate Compose project name keep tests apart from development?
- How do a source bind mount, a named database volume, and the test database’s temporary storage differ?