Laravel with Docker Compose: Adding PostgreSQL and Redis

In the previous article https://codecraftdiary.com/2026/10/25/what-is-docker/, we containerized a small Flask API and started it with a single Compose service. At the end, I mentioned that as the application grows, you can add database and cache services to the same Compose file.

This article is that next step.

This time I am switching to Laravel, which I work with every day. A typical Laravel application needs a database and usually Redis for cache, so it is a good example of a project with more than one service.

The basic workflow stays the same. We still have a Dockerfile, a compose.yaml, and the same docker compose up --build command. We are only adding two services and dealing with the small problems that appear once services start depending on each other.

I assume you already have a Laravel project. A fresh installation works fine for following along.

Create a Dockerfile in the project root:

FROM php:8.4-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip libpq-dev \
    && docker-php-ext-install pdo_pgsql \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /app

COPY composer.json composer.lock ./
RUN composer install --no-interaction --no-scripts --no-autoloader

COPY . .
RUN composer dump-autoload --optimize

EXPOSE 8000

CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
Dockerfile

Most of this should look familiar.

The base image is now php:8.4-cli instead of python:3.13-slim. The PHP image does not include the PostgreSQL driver or the Redis extension, so we install them. git and unzip are there because Composer uses them when downloading packages.

The COPY --from=composer:2 line takes the Composer binary from the official Composer image, so we do not have to install it.

The ordering trick is the same as before. We copy composer.json and composer.lock first and install dependencies, then copy the rest of the code. When you change a controller, Docker reuses the dependency layer instead of running composer install again. The --no-scripts and --no-autoloader options are needed because the application code is not in the image yet at that point. Once it is, composer dump-autoload finishes the job.

The 0.0.0.0 address matters for the same reason it mattered with Flask. And just like the Flask development server, php artisan serve is meant for local development, not for production traffic.

We also need a .dockerignore:

.git
vendor
node_modules
.env
JSON

The vendor line is easy to forget. Without it, COPY . . copies your local vendor directory over the one Composer just installed in the image, and you end up with whatever happens to be on your machine. That is exactly the kind of difference we are trying to avoid.

Now the interesting part. Create compose.yaml next to the Dockerfile:

services:
  app:
    build: .
    ports:
      - "8000:8000"
    env_file:
      - .env
    environment:
      DB_CONNECTION: pgsql
      DB_HOST: db
      DB_PORT: 5432
      DB_DATABASE: app
      DB_USERNAME: app
      DB_PASSWORD: secret
      REDIS_HOST: redis
      CACHE_STORE: redis
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:17
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7

volumes:
  pgdata:
YAML

The app service reads your local .env through env_file. Remember that .env is in .dockerignore, so it never ends up inside the image. Compose passes the values to the container when it starts.

The environment section then overrides the settings that have to be different inside Docker. Values in environment take precedence over values from env_file, so your .env can still say DB_HOST=127.0.0.1 for running the app without Docker.

Look at DB_HOST: db and REDIS_HOST: redis. This is the service-name networking from the previous article in practice. Laravel connects to PostgreSQL using db as the hostname, and no container IP address appears anywhere.

Notice also that db and redis have no ports section. The app container reaches them over the Compose network, so there is no reason to publish them on your machine. If you need a database GUI, add "5432:5432" to the db service. Otherwise, leave it out.

One honest note about secret: a hardcoded password is fine for a local database that only exists on your laptop. It is not how you handle credentials in production.

The depends_on block deserves its own section, because this is where most people get confused the first time.

The short form looks like this:

depends_on:
  - db
YAML

It tells Compose to start the db container before the app container. That is all. It does not wait until PostgreSQL is actually accepting connections.

And PostgreSQL needs a few seconds, especially on the very first run, when it initializes the data directory and creates the database. If you run migrations or send a request during that window, Laravel fails with a connection error. Then you run the same command again a few seconds later, it works, and nobody knows why.

The healthcheck fixes this. pg_isready is a small tool that ships with the PostgreSQL image and reports whether the server accepts connections. Compose runs it every five seconds, and condition: service_healthy makes the app service wait until the check passes.

Redis starts almost instantly, so service_started is good enough for it. You could add a healthcheck there too, but for a local setup I would not bother.

Before starting everything, we need a .env file with an application key:

cp .env.example .env
docker compose run --rm app php artisan key:generate --show
Bash

Copy the printed key into APP_KEY in your .env. The --show option is necessary here. Since .env is not inside the image, key:generate has no file to write to, so we ask it to print the key instead.

Next, add a small route to routes/web.php so we can see that both services work:

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

Route::get('/status', function () {
    return [
        'database' => DB::scalar('select version()'),
        'visits' => Cache::increment('visits'),
    ];
});
PHP

The code is copied into the image at build time, so add the route before building. Now start the project:

docker compose up --build
Bash

In a second terminal, run the migrations:

docker compose exec app php artisan migrate
Bash

Do not skip this step. A fresh Laravel application stores sessions in the database by default, so web routes fail until the sessions table exists.

Open http://localhost:8000/status. You should see something like this:

{
  "database": "PostgreSQL 17.6 on x86_64-pc-linux-gnu, ...",
  "visits": 1
}
JSON

Refresh the page a few times and watch visits go up. The version string comes from PostgreSQL, and the counter lives in Redis. Three containers, one command, and nothing installed on your machine except Docker.

In the previous article, the first beginner mistake was assuming containers are permanent. This is where that lesson becomes practical.

The db service stores its data in the named volume pgdata. Stop the project and start it again:

docker compose down
docker compose up
Bash

The containers are removed and recreated, but your tables are still there. The volume is not part of the container, so docker compose down leaves it alone.

When you actually want a clean database, add -v:

docker compose down -v
Bash

This removes the named volumes too. It is handy when you want to test your migrations from scratch, but make sure you mean it, because everything in that local database is gone.

Redis has no volume in our setup, and that is intentional. We use it as a cache, and a cache should be safe to lose.

A new developer on the project can now clone the repository, create a .env, generate a key, and run docker compose up --build. They do not need PHP, PostgreSQL, or Redis installed locally, and they get the same versions as everyone else on the team.

It is still a development setup. Everything I said last time about production images applies here as well.

There is also one thing that will annoy you quickly: every code change requires a rebuild, because the code is copied into the image. A bind mount fixes that, but it has a few traps of its own, so I will cover it in the next article.

For now, the important part is that your database and cache are no longer instructions in a README. They are part of the project.

Leave a Reply

Your email address will not be published. Required fields are marked *