Symfony Quick Start: Docker & Makefile

Table of Contents
Introduction#
I recently spent a lot of time optimizing my local environment. I realized that I had fallen into the trap of overcomplicating things. I was building huge docker-compose files and hiding logic inside scripts. Because of this, I lost sight of what really matters.
I decided to go back to basics and focus on simplicity. My current setup for Symfony relies on two things: pure Docker and a Makefile. For the development image, I chose the latest PHP version and SQLite support. This allows me to start coding instantly. I do not have to waste time configuring heavy database containers.
Dockerfile#
Let’s start with the image itself. I decided to take a minimalist approach.
FROM php:8.4-fpm-alpine
# Build arguments
ARG PORT=8000
ENV PORT=$PORT
# Composer environment variables
ENV COMPOSER_ALLOW_SUPERUSER=1
ENV COMPOSER_NO_INTERACTION=1
# Install system dependencies
RUN apk add --no-cache \
bash \
curl \
git \
make \
linux-headers \
libzip-dev \
oniguruma-dev \
icu-dev \
sqlite \
sqlite-dev \
&& docker-php-ext-install \
pdo \
pdo_sqlite \
zip \
mbstring \
intl
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Install Gitleaks
RUN wget https://github.com/gitleaks/gitleaks/releases/download/v8.18.2/gitleaks_8.18.2_linux_x64.tar.gz
RUN tar -xvzf gitleaks_8.18.2_linux_x64.tar.gz
RUN mv gitleaks /usr/bin/gitleaks
RUN chmod +x /usr/bin/gitleaks
# Set working directory
WORKDIR /app
# Create non-root user
RUN addgroup -g 1000 appuser && adduser -u 1000 -D -G appuser appuser
# Switch to non-root user
USER appuser
# Expose port for built-in PHP server
EXPOSE $PORT
CMD ["sh", "-c", "php -S 0.0.0.0:$PORT -t public"]
Why did I choose this specific setup? First, I selected Alpine. I want the image to be lightweight and fast to build. Second, permissions are crucial. I run the application as a system user, not as root. This helps me avoid those annoying permission issues with mounted volumes that we all probably know.
However, the most important thing for me is the idea of starting with the absolute minimum. This image contains only what is necessary to start writing code. That is why I choose the HTTP server provided by PHP and SQLite.
Of course, I realize that as the project grows, I will probably add a more advanced HTTP server. I might add specialized databases like MySQL or Redis, or a queue system. And that is fine. I value this approach because it lets me start immediately. I do not want to overwhelm myself right at the start with a massive setup that adds unnecessary complexity before I even write the first line of business logic. I prefer to expand the stack step by step, exactly when I really need it.
Makefile#
The second piece of the puzzle is the convenience of daily work. Instead of writing long commands in the terminal, I reached for good old Make. I treat it here as a collection of shortcuts for frequently used, sometimes complex commands. It allows me to hide long strings of arguments inside short, readable keywords like make up or make test. This significantly speeds up my work in the terminal.
.PHONY: help build up stop sh composer phpcsfixer-fix phpcsfixer-dry-run lint security phpstan phpunit qa rebuild init
# Variables
APP_CONTAINER = symfony-app
PORT = 8000
DOCKER_EXEC = docker exec -t $(APP_CONTAINER)
RUN_COMPOSER = $(DOCKER_EXEC) composer
##@ Help
help: ## Display this help
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
##@ Docker
build: ## Build the Docker image
docker build -t $(APP_CONTAINER) --build-arg PORT=$(PORT) . --no-cache
up: ## Start the app container
docker run --rm -d -v $(PWD):/app --name $(APP_CONTAINER) -p $(PORT):$(PORT) $(APP_CONTAINER)
stop: ## Stop the container
-docker stop $(APP_CONTAINER)
sh: ## Enter the container shell
docker exec -it $(APP_CONTAINER) sh
##@ Development
composer: ## Install Composer dependencies
$(RUN_COMPOSER) install --no-interaction
phpcsfixer-fix: ## Auto-fix code style (PHP-CS-Fixer)
$(DOCKER_EXEC) vendor/bin/php-cs-fixer fix --allow-risky=yes --diff
phpcsfixer-dry-run: ## Check code style (PHP-CS-Fixer in dry-run mode)
$(DOCKER_EXEC) vendor/bin/php-cs-fixer fix --allow-risky=yes --dry-run --diff
##@ Quality Assurance
lint: ## Run PHP linting (cache:clear, lint:yaml, lint:container)
$(DOCKER_EXEC) bin/console cache:clear
$(DOCKER_EXEC) bin/console lint:yaml config/
$(DOCKER_EXEC) bin/console lint:container
security: ## Run security checks (Composer audit + validate + gitleaks)
$(RUN_COMPOSER) audit
$(RUN_COMPOSER) validate --strict
$(RUN_COMPOSER) update roave/security-advisories --dry-run
$(DOCKER_EXEC) gitleaks protect --source .
$(DOCKER_EXEC) gitleaks detect --source .
phpstan: ## Run PHPStan static analysis
$(DOCKER_EXEC) vendor/bin/phpstan analyze src --memory-limit=-1
phpunit: ## Run PHPUnit tests
$(DOCKER_EXEC) vendor/bin/phpunit
qa: phpcsfixer-dry-run lint security phpstan phpunit ## Run full quality checks
##@ Automation
rebuild: stop build up composer lint security phpstan phpcsfixer-dry-run phpunit ## Full rebuild
##@ Setup
init: ## Initialize project (build, start, install dependencies)
@cp -n .env.example .env 2>/dev/null || true
@$(MAKE) stop
@$(MAKE) build
@$(MAKE) up
@sleep 2
@$(MAKE) composer
@echo ""
@echo "Done! App running at http://localhost:$(PORT)"
Summary#
I did’t start from scratch when designing this file. I was heavily inspired by the article The perfect MakeFile for Symfony from Strangebuzz. That is where I found the great auto-documentation mechanism, the help command. However, I simplified the content a lot. I adapted it to fit my “one container” philosophy.
Here, I also follow the rule of full transparency. I stopped using scripts inside composer.json. I prefer to see exactly what is happening. When I run static analysis or tests, I want to know which flags are used. For example, I want to see the memory limits or risky modes clearly.
The Makefile acts as my command center. I have one file that handles building the image, starting the container, and all quality assurance tools. Because of this, I do not have to remember long commands. I do not have to guess what a script is doing “under the hood.” This solution is also very flexible. Anyone can adjust it to their own needs.
This approach gives me full control and understanding of my environment. Of course, this is just a base that can be expanded. But as a starting point, it works perfectly. It is simple, fast, and clear.