How to Build Your Own Docker Image (A Dockerfile for Your Script or App)

Everything so far has used images other people built. Sooner or later you have a Python script that polls an API, a little Flask app, or a Node bot, and you want it to run like the rest of the server: in a container, restarted on failure, updated with compose up. A Dockerfile is a short recipe — start from a base image, copy the code in, install what it needs, say how to start it — and docker compose build turns it into an image.
Quick reference
| Task | What |
|---|---|
| Recipe | Dockerfile next to your code |
| Build | docker build -t myapp . or docker compose build |
| Run | docker run --rm myapp or build: in compose + up -d |
| Keep it small | python:3.12-slim, node:22-alpine, .dockerignore |
| Don't run as root | USER app |
| Rebuild after a code change | docker compose up -d --build |
1. A Python script
Say ~/code/weather-bot/bot.py with a requirements.txt.
nano ~/code/weather-bot/Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN useradd --create-home app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER app
CMD ["python", "bot.py"]
Line by line: start from a small official Python; work in /app; make a non-root user; copy only the requirements first and install them (so this slow step is cached while you edit code); copy the rest; drop to the user; and the command that runs when the container starts.
nano ~/code/weather-bot/.dockerignore
.git
__pycache__
*.pyc
.env
data/
Without this, COPY . . drags your git history and secrets into the image.
2. Build and run
cd ~/code/weather-bot
docker build -t weather-bot .
docker run --rm -e API_KEY=xxx weather-bot
-t tags it with a name. The first build downloads the base image; later builds reuse cached layers and take seconds unless requirements.txt changed.
3. Run it with compose like everything else
nano ~/code/weather-bot/compose.yaml
services:
weather-bot:
build: .
container_name: weather-bot
env_file: .env
volumes:
- ./data:/app/data
restart: unless-stopped
docker compose up -d --build
docker compose logs -f
build: . tells compose to build from the Dockerfile in this folder instead of pulling. After editing the code: docker compose up -d --build again.
4. A Node app and a static site
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
A static site is even shorter — build it, serve it with Nginx:
FROM nginx:alpine
COPY public/ /usr/share/nginx/html/
5. Multi-stage: build tools out, result in
A site generated by Hugo or a compiled Go program needs tools to build but not to run:
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o app
FROM gcr.io/distroless/static
COPY --from=build /src/app /app
USER nonroot
CMD ["/app"]
The final image is a few megabytes and contains nothing but your program.
6. Good habits
- Pin the base (python:3.12-slim, not python:latest) so a rebuild next year still works.
- One process per container. A script and its database are two services in compose, not one image.
- Config via environment, data via volumes — never baked into the image.
- Healthcheck so compose knows it's really up: HEALTHCHECK CMD curl -f http://localhost:3000/ || exit 1.
- docker image prune after a few rebuilds; old layers pile up.
Share it with the Pi
Push to Gitea's container registry (tutorial 45: docker tag weather-bot git.example.com/john/weather-bot && docker push ...), or just copy the folder and compose up -d --build there. For a Pi, build on the Pi (ARM) or use docker buildx build --platform linux/arm64.
Troubleshooting
"COPY failed: file not found". The path is relative to the build context (the . in docker build). Files in .dockerignore are invisible too.
Image is 1 GB. You used python:3.12 instead of -slim, or installed build tools and left them. Slim base, --no-cache-dir, multi-stage.
Permission denied writing to the volume. The container runs as app (UID 1000 inside) but the host folder is owned by someone else. Match the UIDs or chown the folder.
Changes to the code don't appear. You ran up -d without --build. Compose reuses the old image unless told to rebuild.
Wrapping up
A ten-line Dockerfile, a .dockerignore, build: . in compose, up -d --build. Your own script becomes a first-class citizen of the server — restarted, logged, limited and backed up like everything else — and you never again wonder which Python version it needs.
