I wanted to Dockerize my FastAPI application and keep separate configurations for local development and production.
My application was a CRM backend using FastAPI, PostgreSQL, and Alembic. My goal was to run it locally with Docker, push the changes to GitHub, and deploy the same project on my Ubuntu VPS.
During this process, I had several doubts about environment variables, database connections, YAML configuration, and port settings. After deployment, I also faced an issue where the API worked inside the VPS but could not be accessed from outside.
This is how I worked through the setup and what I learned.
1. Keeping Local and Production Configuration Separate
I started by learning how to install Docker and check that Docker Compose was available.
Then my first question was how to keep Docker files inside my project for both local development and deployment.
My setup used:
Dockerfile
compose.yaml
compose.production.yaml
.env
.env.productionThe Dockerfile builds the application image. The two Compose files control how the services run in each environment.
Both environments had three main services:
| Service | Purpose |
|---|---|
db | Run PostgreSQL |
migrate | Run Alembic migrations |
api | Run FastAPI |
For local development, I wanted code changes to appear quickly. My API service used:
command:
["uvicorn", "app.main:app", "--host", "0.0.0.0",
"--port", "8000", "--reload"]
volumes:
- ./app:/app/app:roThe mount makes my local application files available inside the container. The --reload option restarts the development server when the code changes.
My local API port mapping was:
ports:
- "127.0.0.1:8000:8000"For production, I did not use development reload or source-code mounts. The application ran from the built image.
This gave me one project with separate settings for development and deployment.
2. My Database Doubt Was About More Than localhost
One of my main doubts was about having different database values in .env and Compose.
Initially, my .env contained:
DATABASE_URL=postgresql+asyncpg://crm:crm@localhost:5432/crmBut my API and migration services had this explicit setting:
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://crm:crm@db:5432/crmI wanted to understand which value Docker would actually use.
Then I asked a more specific question: what if I changed .env to this?
DATABASE_URL=postgresql+asyncpg://crmnew:crmnew@localhost:5432/crm_newMy PostgreSQL service still had:
environment:
POSTGRES_DB: crm
POSTGRES_USER: crm
POSTGRES_PASSWORD: crmWould Docker use the new database values automatically?
The answer was no.
There were two separate things to understand.
First, the explicit environment value in my API service overrides the same variable from env_file. So the API container would still receive:
postgresql+asyncpg://crm:crm@db:5432/crmSecond, PostgreSQL does not create a database based on the API’s DATABASE_URL.
The PostgreSQL container uses POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD when initializing a new database.
The API uses DATABASE_URL to connect to that database.
Changing only the URL does not automatically change the PostgreSQL database or user. The connection details need to match the actual database setup.
These were questions I checked while configuring the application. I did not have a confirmed database connection failure in the shared history.
3. Why Docker Uses db Instead of localhost
The hostname was another part of the same discussion.
Inside the API container, localhost means the API container itself. It does not mean the PostgreSQL container.
My PostgreSQL service was named db:
services:
db:
image: postgres:17Docker Compose allows containers on its network to connect using service names. So my API and migration containers needed to use:
db:5432The address depends on where the application runs:
| Application location | Database address |
|---|---|
| Running directly on my computer, using the published database port | localhost:5432 |
| Running inside Docker Compose | db:5432 |
This applies to local Docker development too. It is not only a production rule.
Later, I changed my PostgreSQL configuration to read values from .env:
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}This made it easier to manage the database settings.
However, any database URL still hardcoded under environment would continue to override the URL in .env. I needed to remember that when changing credentials.
4. Starting PostgreSQL, Migrations, and the API in Order
My application uses Alembic to manage database changes.
I kept migrations in a separate service:
migrate:
build: .
image: async-crm:local
command: ["alembic", "upgrade", "head"]
env_file: .env
depends_on:
db:
condition: service_healthy
restart: "no"The migration service waits until PostgreSQL passes its healthcheck.
Then the API waits for migrations to finish successfully:
depends_on:
migrate:
condition: service_completed_successfullyThe startup order was:
PostgreSQL becomes healthy
↓
Alembic runs migrations
↓
Migrations finish successfully
↓
FastAPI startsI also reviewed the database healthcheck. Instead of keeping the username and database name hardcoded, the suggested version was:
healthcheck:
test:
["CMD-SHELL",
"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10The double $ allows those variables to be expanded inside the container.
Another useful point was that the migration container is expected to stop after finishing. An exit code of 0 means it completed successfully.
5. Understanding <<: *app in My Production File
While reviewing my production Compose file, I saw:
api:
<<: *appI asked what <<: *app meant.
My file defined common application settings like this:
x-app: &app
image: ${CRM_IMAGE:-async-crm:production}
build: .
environment:
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production}
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env.production}
ACCESS_TOKEN_MINUTES: ${ACCESS_TOKEN_MINUTES:-60}Both the API and migration services reused those settings.
I learned that this is YAML syntax:
&appdefines an anchor namedapp.*apprefers to that anchor.<<:merges those settings into the service.
This helped me understand why the same image, environment variables, and other settings did not need to be repeated for both services.
6. What Changed in Production
My production configuration included settings such as:
init: true
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueIt also limited the size and number of container log files.
The application container had a read-only filesystem, with /tmp available for temporary files. This meant I needed to consider whether the application tried to write uploads or files somewhere else.
These settings were reviewed during the discussion. They were not confirmed causes of an error.
Another difference was PostgreSQL access.
Locally, I published port 5432 so I could connect from my computer. In production, I did not publish the database port. The API and migrations could still connect through db:5432 on the Docker network.
PostgreSQL data was stored in a named volume:
volumes:
- crm_data:/var/lib/postgresql/dataThis kept the data separate from the container. I also learned to avoid removing that volume accidentally, especially with docker compose down -v.
7. Moving the Project to My VPS
After pushing my Docker changes to GitHub, I pulled them onto the server.
Docker was already installed on the VPS.
I created .env.production with the database settings, JWT secret, image name, and API port settings.
The production database URL used db, and its credentials matched the PostgreSQL settings.
The next steps were to validate the configuration, build the image, and start the services.
That was where I faced my first actual command error.
8. The Missing Environment Variables Error
I ran:
docker compose -f compose.production.yaml psCompose returned errors saying required variables were missing, including:
DATABASE_URL
JWT_SECRET
POSTGRES_PASSWORDI checked the directory with ls -la. The .env.production file was already there.
So the issue was not that the file was missing. Compose had not been told to load it.
My production file used expressions such as:
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production}Compose needs the value while processing the YAML file.
The correct command was:
docker compose -f compose.production.yaml \
--env-file .env.production psThis helped me understand another difference:
| Setting | Purpose |
|---|---|
Service-level env_file | Pass variables into a container |
Command-level --env-file | Provide values for Compose variable expressions |
I then validated the configuration:
docker compose -f compose.production.yaml \
--env-file .env.production configThis time, Compose loaded the values and displayed the resolved configuration.
9. Deployment Worked, but I Repeated the Same Command Mistake
I started the production services:
docker compose -f compose.production.yaml \
--env-file .env.production up -d --buildThe output showed:
Image async-crm:production Built
Container async-crm-production-db-1 Healthy
Container async-crm-production-migrate-1 Exited
Container async-crm-production-api-1 StartedThe build completed, PostgreSQL became healthy, and the API started after the migration service finished.
Then I ran ps again without --env-file .env.production.
The same missing-variable errors appeared.
This was confusing because deployment had just succeeded. But the status command was failing while processing the Compose file, before it could show the containers.
I learned to use the complete production command consistently:
docker compose -f compose.production.yaml \
--env-file .env.production <command>That included commands for status, logs, and deployment.
10. Confusion Between Host Port 8003 and Container Port 8000
Next, I tried accessing the application through the VPS public IP on port 8000.
It did not open. Since Nginx was already configured, I wondered whether Nginx was causing the issue.
When I checked the running containers, the API mapping showed:
127.0.0.1:8003->8000/tcpThis told me two things:
- The VPS port was
8003. - That port was bound to the VPS’s localhost address.
The application still ran on port 8000 inside the container.
The mapping meant:
VPS port 8003 → Container port 8000I did not need to change the application’s internal port to 8003.
My Nginx syntax check also succeeded. However, that only confirmed the configuration syntax was valid. It did not confirm that requests were reaching the correct upstream port.
If Nginx on the host forwards requests to this API, its upstream needs to use the published host port:
proxy_pass http://127.0.0.1:8003;11. The API Worked Inside the VPS but Failed From Outside
I clarified that I wanted direct access through the VPS IP and port 8003.
I tested the API inside the VPS:
curl -I http://127.0.0.1:8003/docsThe response was:
HTTP/1.1 200 OK
server: uvicornBut this still failed from my computer:
http://<VPS-IP>:8003/docsThe successful local response was useful evidence. FastAPI was responding through Docker on the VPS.
The remaining issue was how outside requests could reach it.
My environment still had:
API_BIND_ADDRESS=127.0.0.1
API_PORT=8003That created a localhost-only port mapping.
For the direct external access I wanted, the required setting was:
API_BIND_ADDRESS=0.0.0.0
API_PORT=8003After changing it, the container needed to be recreated:
docker compose -f compose.production.yaml \
--env-file .env.production up -d --buildThe expected port mapping was then:
0.0.0.0:8003->8000/tcpAt the end of the discussion, I realised I had kept 127.0.0.1 and had not changed it. That explained why direct access from outside was failing.
12. Checking UFW and Understanding Nginx’s Role
During troubleshooting, I checked:
sudo ufw statusIt returned:
Status: inactiveI also ran:
sudo ufw allow 8003/tcpThe command updated the rules, but UFW was inactive. That did not change the localhost-only Docker binding.
The VPS provider’s firewall was another possible check if external access still failed after changing the binding. The history did not confirm a provider firewall problem.
I also learned that Nginx being installed does not automatically block direct access to port 8003.
There are two different access paths:
Through Nginx:
Browser → Nginx → 127.0.0.1:8003 → Container:8000
Direct access:
Browser → VPS-IP:8003 → Container:8000For access through Nginx on the VPS, keeping the API bound to 127.0.0.1 can make sense.
For direct external access, the API port needs an externally reachable binding, along with network rules that allow the connection.
What This Process Taught Me
The main learning was to check the actual configuration and command output instead of assuming what Docker was using.
My .env values could be overridden by Compose. A production environment file could exist without being loaded. A deployment could succeed while a later status command failed. And an API could return 200 OK inside the VPS while remaining unavailable from outside.
By the end of the discussion, I had confirmed that the image built, PostgreSQL became healthy, and FastAPI responded inside the VPS. I had also identified the unchanged 127.0.0.1 binding as the reason direct external access failed.
The shared history ends with that finding and the required fix. It does not include a successful external test after the change.
For me, this Docker setup became a practical way to understand environment variables, database connections, migration order, and the difference between a running application and an application that outside users can reach.




