The gap
make build-stack gets you a working stack on a laptop. There is no equivalent
for a server, and building on the server can impact hosted services.
Both Dockerfiles are built for the bind mounts in docker-compose.yml.
backend/Dockerfile copies requirements.txt and nothing else;
frontend/Dockerfile copies package.json and nothing else. The application
code arrives at runtime from ./backend:/app and ./frontend:/app. So the
images contain dependencies and no source. They cannot run anywhere the source
tree is not mounted.
The startup commands are also local-only. The backend runs
uvicorn --reload, which watches the filesystem. The frontend runs
npm run dev, which is the Vite dev server rather than the output of
npm run build.
What could be added
Two production Dockerfiles and one workflow, sitting beside the dev ones. The
dev files stay untouched, so make stack and hot reload keep working.
backend/Dockerfile.prod is the current file with COPY . . added and
--reload dropped. Migrations still run on start:
CMD ["sh", "-c", "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000"]
frontend/Dockerfile.prod is a two-stage build: npm run build in the node
stage, then copy dist/ into an nginx image that serves the static files and
proxies /api/ to the backend. That folds the proxy service into the
frontend image and drops a container from the production stack. The routing
rules already exist in proxy/nginx.conf; they need the dev-server proxying
and the HMR WebSocket headers removed.
Then a workflow that publishes both on a tag:
name: release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
strategy:
matrix:
include:
- { name: api, context: ./backend, dockerfile: ./backend/Dockerfile.prod }
- { name: web, context: ./frontend, dockerfile: ./frontend/Dockerfile.prod }
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: true
tags: ghcr.io/${{ github.repository }}-${{ matrix.name }}:${{ github.ref_name }}
Tagging v1.2.3 then produces two images at the same tag. The shared tag is
worth keeping: deploy tooling usually takes one tag and applies it across every
service in a release, and separately versioned images make that impossible.
A docker-compose.prod.yml reading image: ${API_IMAGE} and
image: ${WEB_IMAGE} would round it out, but the images are the part that
cannot be worked around downstream.
Three defaults that block a deploy
Separate from the build, and cheap to fix:
The db healthcheck is hardcoded to the example credentials:
test: ["CMD-SHELL", "pg_isready -U user -d deltav_db"]
It ignores POSTGRES_USER and POSTGRES_DB. Change either one in .env, as
you must for a real deployment, and the check never passes. backend waits on
condition: service_healthy, so the stack hangs with no obvious cause.
pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} fixes it and behaves the
same locally.
backend/auth.py:45 falls back to a literal when JWT_SECRET is unset:
SECRET_KEY = os.environ.get("JWT_SECRET", "dev-secret-change-me")
A deployment that forgets the variable signs tokens with a value published in
this repository, and nothing in the logs says so. Reading os.environ["JWT_SECRET"]
turns a silent auth bypass into a startup error. If the fallback is worth
keeping for the first make stack, gating it on an explicit dev flag keeps the
convenience without the failure mode.
The db service publishes 5432:5432. Useful for pgAdmin on a laptop, and it
exposes Postgres to the network on a server. A comment on that line saying to
remove it in production would be enough.
Context
I hit this wiring the boilerplate up to an ops toolkit that deploys by pulling
prebuilt images. The stack itself needed no changes, only the images. Happy to
open a PR with the two Dockerfiles, the nginx config, and the workflow if the
approach looks right.
Version this is against:
8cc51e3.
The gap
make build-stackgets you a working stack on a laptop. There is no equivalentfor a server, and building on the server can impact hosted services.
Both Dockerfiles are built for the bind mounts in
docker-compose.yml.backend/Dockerfilecopiesrequirements.txtand nothing else;frontend/Dockerfilecopiespackage.jsonand nothing else. The applicationcode arrives at runtime from
./backend:/appand./frontend:/app. So theimages contain dependencies and no source. They cannot run anywhere the source
tree is not mounted.
The startup commands are also local-only. The backend runs
uvicorn --reload, which watches the filesystem. The frontend runsnpm run dev, which is the Vite dev server rather than the output ofnpm run build.What could be added
Two production Dockerfiles and one workflow, sitting beside the dev ones. The
dev files stay untouched, so
make stackand hot reload keep working.backend/Dockerfile.prodis the current file withCOPY . .added and--reloaddropped. Migrations still run on start:frontend/Dockerfile.prodis a two-stage build:npm run buildin the nodestage, then copy
dist/into an nginx image that serves the static files andproxies
/api/to the backend. That folds theproxyservice into thefrontend image and drops a container from the production stack. The routing
rules already exist in
proxy/nginx.conf; they need the dev-server proxyingand the HMR WebSocket headers removed.
Then a workflow that publishes both on a tag:
Tagging
v1.2.3then produces two images at the same tag. The shared tag isworth keeping: deploy tooling usually takes one tag and applies it across every
service in a release, and separately versioned images make that impossible.
A
docker-compose.prod.ymlreadingimage: ${API_IMAGE}andimage: ${WEB_IMAGE}would round it out, but the images are the part thatcannot be worked around downstream.
Three defaults that block a deploy
Separate from the build, and cheap to fix:
The
dbhealthcheck is hardcoded to the example credentials:It ignores
POSTGRES_USERandPOSTGRES_DB. Change either one in.env, asyou must for a real deployment, and the check never passes.
backendwaits oncondition: service_healthy, so the stack hangs with no obvious cause.pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}fixes it and behaves thesame locally.
backend/auth.py:45falls back to a literal whenJWT_SECRETis unset:A deployment that forgets the variable signs tokens with a value published in
this repository, and nothing in the logs says so. Reading
os.environ["JWT_SECRET"]turns a silent auth bypass into a startup error. If the fallback is worth
keeping for the first
make stack, gating it on an explicit dev flag keeps theconvenience without the failure mode.
The
dbservice publishes5432:5432. Useful for pgAdmin on a laptop, and itexposes Postgres to the network on a server. A comment on that line saying to
remove it in production would be enough.
Context
I hit this wiring the boilerplate up to an ops toolkit that deploys by pulling
prebuilt images. The stack itself needed no changes, only the images. Happy to
open a PR with the two Dockerfiles, the nginx config, and the workflow if the
approach looks right.
Version this is against:
8cc51e3.