The Dockerfile: Forging a Secure and Efficient Container

Ever found yourself staring at your terminal, a fresh FastAPI application humming along in Docker, and a nagging feeling that something isn’t quite right? You’ve got your `Dockerfile`, your `docker-compose.yaml`, and everything *seems* to be in place. Then, it’s time for the moment of truth: running that Alembic migration command within your container. You execute it, see the success message, but when you check your local project directory, the new version file is nowhere to be found. Frustrating, right?
That’s exactly the rabbit hole I tumbled into. The “natural” solution, a simple bind mount, felt like it should be an easy fix. But then the permissions gremlins appeared, and suddenly, the dream of having my Alembic migration versions sync directly to my host machine while simultaneously enjoying Docker Compose’s fantastic `watch` feature seemed like an impossible ask. I saw solutions that suggested sacrificing one for the other, but honestly? I’m too stubborn for that. I wanted my cake, and I definitely wanted to eat it too!
This article is a deep dive into how I conquered that particular beast. It’s a fix that should also resonate with folks using Flask, and while Django users might need to adapt a bit, the core principles remain solid. We’ll be leveraging the brilliant UV for package dependency management – a tool I can’t recommend enough. Enough war stories, let’s get to the setup.
The Dockerfile: Forging a Secure and Efficient Container
Our journey begins with crafting a robust `Dockerfile`. The goal here isn’t just to get our application running, but to build an image that is secure, efficient, and plays nicely with our development workflow. Many of these choices are designed to prevent future headaches, especially around permissions and build times.
# 1. Use Python 3.13 bookworm as the base
FROM ghcr.io/astral-sh/uv:python3.13-bookworm # 2. Set the working directory
WORKDIR /app # 3. Set environment variables for UV
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy # 4. create a non-root user for security
RUN useradd -u 1000 app # 5. Set HOME explicitly to /app
ENV HOME=/app # 6. Ensures the /app directory (and everything inside) is owned by the app user
RUN chown -R app:app /app # 7. Copy pyproject.toml and uv.lock with correct ownership
COPY --chown=app:app pyproject.toml uv.lock ./ # 8. Switch to the non-root user BEFORE installing dependencies
USER app # 9. Install dependencies
RUN uv sync --frozen --no-install-project --no-dev # 10. Copy the rest of the application code
COPY --chown=app:app . .
RUN uv sync --frozen --no-dev # 11. Expose the port
EXPOSE 8000 # 12. Command to run the application
CMD ["uv", "run", "fastapi", "dev", "main.py", "--host", "0.0.0.0", "--port", "8000"]
User Management and Permissions: The Root of Many Evils
Let’s unpack some crucial decisions in this `Dockerfile`. In step 4, we create a non-root user named `app` and assign it a User ID (UID) of 1000. Why 1000? Many Linux distributions assign this UID to the first regular user created on a system. By aligning our container’s UID with a common host UID, we significantly reduce file permission conflicts when bind mounts come into play.
Following this, step 6, `RUN chown -R app:app /app`, ensures that our application directory within the container is owned by this new `app` user *before* we even switch to it. This is a subtle but powerful move, ensuring everything we do afterwards, including copying files and installing dependencies, adheres to the correct ownership from the get-go. This is a common pitfall that trips up many Docker users.
Dependency Management with UV: Speed and Consistency
Steps 5 and 9 highlight our use of UV. When UV installs dependencies, it often caches them. By setting `ENV HOME=/app` (step 5), we direct UV’s cache to `/app/.cache/uv` within the container. Without this, you might encounter `”/nonexistent/.cache/uv”` errors, as UV attempts to write to a non-existent or unwritable default home directory for the non-root user. For development, this caching is a godsend, speeding up subsequent builds. For production, you might consider the `UV_NOCACHE=1` flag to keep image sizes lean.
In step 9, `uv sync –frozen –no-install-project –no-dev` is a carefully crafted command. `–frozen` ensures consistent dependency installations across environments, pulling from your `uv.lock` file. `–no-install-project` prevents installing our own project’s code here, which is key for efficient layer caching. If your application code changes, this layer doesn’t need to be rebuilt, only the subsequent `COPY` and final `uv sync` in step 10. `–no-dev` is self-explanatory, keeping development dependencies out of our core image.
Getting this Dockerfile right is a major step. Congratulations if you’ve made it through that explanation – the heavy lifting is mostly done!
Docker Compose: Orchestration and the “Magic” Fix
Now, let’s define our `docker-compose.yaml` (yes, the Docker docs prefer `yaml` over `yml`). Our goal is to spin up our FastAPI application alongside a PostgreSQL database, all while enabling live reloading and ensuring our Alembic migrations behave.
services: db: image: postgres env_file: - .env ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql restart: always api: build: . env_file: - .env depends_on: - db ports: - "8000:8000" develop: watch: - path: ./src action: sync target: /app/src - path: ./pyproject.toml action: rebuild working_dir: /app volumes: - ./migrations:/app/migrations:z - ./migrations/versions:/app/migrations/versions:z volumes: postgres_data:
Clean Configuration with .env
You’ll notice I prefer a clean and concise `docker-compose.yaml`. This is where the `.env` file comes in handy. Instead of cluttering your `yaml` with a long list of `environment` variables, simply specify `env_file: – .env`. It keeps things tidy and makes your configuration much more readable. Remember, you can easily load these into your Python application using `os.environ`.
The Develop Section: Watching Your Code Evolve
The `develop` section under our `api` service is where the “watch” magic happens. This feature from Docker Compose (version 2.19.0+) is a game-changer for development. We’re telling Compose to:
- `watch` the `./src` directory on our host. If changes occur, `sync` them to `/app/src` inside the container. This handles live code updates.
- `watch` our `pyproject.toml`. If this file changes, `rebuild` the container, ensuring any new dependencies or project configurations are picked up.
This is a critical part of having our “cake” – the seamless development experience with automatic code reloading.
Unlocking Permissions: The Elusive ‘z’ Flag
Now, for the “eating it too” part – the solution to our bind mount permission woes. Take a close look at the `api.volumes` section:
volumes: - ./migrations:/app/migrations:z - ./migrations/versions:/app/migrations/versions:z
We’re setting up two bind mounts: one for the root `migrations` directory and another specifically for `migrations/versions`. This separation ensures that both the main Alembic configuration and the generated version files are accessible on the host. But the real hero here is the `:z` flag appended to each volume definition.
What does `:z` do? In our context, it instructs Docker to relabel the SELinux security context of the host path. This is crucial for systems using SELinux (like many Linux distributions). Without this flag, SELinux prevents the container from accessing the host directory, leading to those frustrating permission denied errors. By relabeling, we grant the container the necessary permissions to read and write to those bind-mounted directories.
With this simple addition, when Alembic generates a new migration file inside your container, it will instantly appear in your local `migrations/versions` directory on your host machine. And yes, you still get to use the `develop.watch` feature without any fuss. Finally, we get to have our cake and eat it!
A quick note: For users on macOS or Windows, which are common Docker development environments, this flag might be unnecessary and subsequently ignored by Docker. It’s worth testing in your specific setup!
Bringing It All Together: Setup and Execution
Before you run everything, a few preparatory steps:
.dockerignore
__pycache__/
.venv
.ruff_cache
./src/email_templates/
.env
Make sure you have a `.dockerignore` file. This prevents unnecessary files from being copied into your Docker image, keeping it lean and speeding up builds. Files like virtual environments (`.venv`), cache directories, and `.env` files don’t belong in the final image.
Next, set up your Alembic migrations on your host:
- Create a directory for your Alembic migrations, typically named `migrations`.
- Inside that, create a `versions` sub-directory.
- Run `alembic init` in your project root (or wherever you prefer).
- Crucially, edit the `script_location` variable in your `alembic.ini` to point to your `migrations` directory (e.g., `script_location = migrations`).
- Also, ensure your `env.py` file within the `migrations` directory is correctly configured to connect to your database (using environment variables from your `.env` file, loaded via `os.environ`).
With everything in place, it’s time to launch our stack:
docker compose up --watch
This command builds your services, brings them up, and activates the `watch` feature for live reloading.
In a second terminal, you can now run your Alembic commands, and watch the magic happen locally:
docker compose exec api uv run alembic revision --autogenerate -m "Initial"
docker compose exec api uv run alembic upgrade head
You’ll see the migration script generated in your container, and almost instantly, that new version file will appear right there in your local `migrations/versions` directory! Success!
Conclusion
If you’ve followed along, I hope you’ve gained a deeper understanding of how to manage permissions, optimize Docker builds, and truly integrate your development workflow with containers. The journey to solve this specific FastAPI, Alembic, and Docker challenge was an insightful one, highlighting how seemingly small details like a non-root user or a single `:z` flag can make all the difference.
These solutions are born from personal experience and countless hours of troubleshooting. While they work brilliantly for me, remember that your specific use case or system configuration might require slight adjustments. The key is to understand the rationale behind each choice – the why, not just the how. Keep experimenting, keep learning, and never settle for solutions that force you to compromise your ideal development experience. Feel free to connect if you have any questions or different approaches!




