
Pull the code with git, install the packages globally with pip, start the process inside a screen session, and it works. It keeps working for months. Then a routine package upgrade moves a shared library underneath the application, the process refuses to start, and nothing on the box records what was installed or why. The same code still runs perfectly on the laptop it was written on, which is the detail that makes the evening long: the difference isn’t in the code, it’s in an environment nobody ever wrote down. That is the argument for containerising even a single small service, and it has nothing to do with scale or fashion. A container is a written, repeatable answer to the question “what does this thing need in order to run”. Recollection is not an answer. A file committed next to the code is.
the shape of the setup

Here is the arrangement I’d recommend for a single small box, and it’s the same whether the app is Django, Express, Rails or Go. One reverse proxy on the host owns ports 80 and 443 and is the only thing the internet can reach. Behind it, your application container listens on a high port bound to loopback. If there’s a database, it runs in another container on a private network with no published ports at all.
That separation buys you three things. TLS lives in one place, so certificates are a solved problem once rather than per-app. The proxy can serve static files, add headers, handle redirects and rate-limit abusive clients without your application knowing. And when you add a second app next year, it slots in behind the same proxy on a different hostname instead of needing its own port opened.
Everything below assumes a machine you’ve already hardened: a non-root user with sudo, key-only SSH, a default-deny firewall. If any of that is missing, start with my Ubuntu server security checklist, because Docker has a habit of making firewall mistakes louder than they’d otherwise be.
installing Docker without being careless about it

On a server you intend to keep, follow Docker’s apt-repository instructions for Ubuntu linked under Sources; Docker’s own documentation says the convenience script isn’t recommended for production. If you do use the script on a throwaway box, download it and read it before you run it. Piping any remote script straight into a shell means executing whatever that URL happens to serve at that moment, and the two seconds it takes to open the file first is a good habit to keep.
# throwaway or test boxes only: fetch first, read it, then decide
curl -fsSL https://get.docker.com -o install-docker.sh
less install-docker.sh
sudo sh install-docker.sh
# optional: docker without sudo; the docker group is equivalent to root on this host
sudo usermod -aG docker "$USER"
docker --version
docker compose version
Docker’s documentation warns that the docker group grants root-level privileges, so add only an account you’d already trust with sudo.
a Dockerfile that behaves in production

Most example Dockerfiles online are written for convenience, not for running unattended. Four changes make a real difference: a base image pinned at least to the Python minor version and Debian release (pin the digest as well for fully repeatable builds), a non-root user, a real process manager rather than the framework’s development server, and a health endpoint the proxy can check.
FROM python:3.12-slim-bookworm
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# don't run as root inside the container either
RUN useradd --create-home --shell /usr/sbin/nologin appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", \
"--access-logfile", "-", "myapp.wsgi:application"]
COPY . . copies the whole build context into the image, and here that context includes .env and secrets/. Add a .dockerignore next to the Dockerfile so they never reach a layer:
# .dockerignore
.env
secrets/
.git
__pycache__/
Bind to 0.0.0.0 inside the container, because the container’s own network namespace is not the host’s. The restriction to loopback happens at the publish step, which is the next file. Mixing these two up is the single most common mistake in this whole setup and it’s worth pausing on.
the compose file, and why loopback matters

services:
web:
build: .
image: myapp:2026-06-25
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:8000:8000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4)"]
interval: 30s
timeout: 5s
retries: 3
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: myapp
volumes:
- dbdata:/var/lib/postgresql/data
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 5
volumes:
dbdata:
secrets:
db_password:
file: ./secrets/db_password.txt
the 127.0.0.1 prefix is the whole security story
Writing "8000:8000" publishes the port on every interface, reachable from the public internet. Writing "127.0.0.1:8000:8000" publishes it only on loopback, so nothing outside the machine can touch it and only the proxy can. Those eleven extra characters are the difference between an app you control access to and one that’s simply exposed.
This matters more than people expect, because Docker writes its own firewall rules and they sit ahead of ufw in the chain. A published port can be reachable even when ufw status insists that port is denied. The usual casualty is a database somebody assumed the firewall was covering. Notice the db service above publishes nothing at all; the app reaches it over the compose network by service name, and that is the correct amount of exposure for a database.
the proxy in front


This guide runs nginx on the host, outside Docker, because it’s one less container in the dependency chain and certbot’s nginx plugin then works exactly as documented. Caddy is the other sensible answer, and for a first deployment it may be the better one, since it obtains and renews certificates itself with no extra tooling.
| Concern | nginx on the host | Caddy |
|---|---|---|
| TLS certificates | certbot, a separate step | Automatic, built in |
| Config verbosity | Long but explicit | Very short |
| Documentation and examples | Enormous, twenty years deep | Good, much smaller pool |
| Static file serving | Excellent | Excellent |
| Best fit | You already know nginx | First deployment, fewest moving parts |
The nginx side looks like this. Only the proxy block is shown; the TLS lines are what certbot adds for you, and the mechanics of that are in my write-up on free HTTPS with Let’s Encrypt.
server {
listen 443 ssl;
server_name app.technobabu.com;
# certbot manages these two lines
ssl_certificate /etc/letsencrypt/live/app.technobabu.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.technobabu.com/privkey.pem;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
Those forwarded headers are not decoration. Without them your app sees every request as coming from 127.0.0.1 over plain HTTP, which breaks rate limiting, breaks anything logging client addresses, and makes some frameworks emit http:// redirects that loop. The Caddy equivalent is genuinely two lines:
app.technobabu.com {
reverse_proxy 127.0.0.1:8000
}
secrets, and where not to put them

An .env file is adequate for a small deployment on two conditions: it never reaches git, and its permissions are tight. File-based secrets, as in the compose example above, go further by keeping values out of the container environment where docker inspect would print them.
printf '%s\n' '.env' 'secrets/' >> .gitignore
chmod 600 .env
mkdir -p secrets
chmod 700 secrets
# generate a real random value instead of inventing one
openssl rand -base64 36 > secrets/db_password.txt
Never bake a secret into a Dockerfile with ENV or COPY. It stays in the image layers permanently, and anyone who can pull that image can read it back out, including after you’ve “removed” it in a later layer.
deploying updates, and rolling back

Tag images with something meaningful: a date or a git commit, not latest, which makes rollback a guessing game. A deploy is then pull, build, up. A rollback is editing one line back to the previous tag.
git pull
docker compose build
docker compose up -d # recreates only what changed
docker compose ps
docker compose logs --tail=50 web
# rollback: point image: at the previous tag, then
docker compose up -d --no-build web
# what tags do I still have locally?
docker image ls myapp
This is not zero downtime, and I’d rather say so than pretend. There’s a gap of a second or two while the container restarts, and anyone mid-request sees an error. For most small sites that’s acceptable. If it isn’t, run two replicas and restart them one at a time, but understand you’ve taken on real complexity, including migrations that must work against both versions at once.
the deployment checklist I’d run through

- DNS points at the box and resolves from outside, not just from your laptop’s cache.
sudo ss -tlnpshows the app on 127.0.0.1 only, and the database on nothing public.- A port scan from your laptop confirms only 22, 80 and 443 answer.
- HTTPS works, HTTP redirects to it, and renewal has been dry-run once.
restart: unless-stoppedis set, and you’ve rebooted the box once to prove the app comes back by itself.- Database volume is named, and a dump has been taken and restored somewhere else at least once.
- An external check watches the real URL, not the port.
Step five is the one people skip and then regret after a provider maintenance reboot. Step seven is worth its own afternoon, which is why I wrote up how to know your server is down before your users tell you separately.
common mistakes

- Publishing ports without the loopback prefix, then assuming ufw is protecting them. Docker’s rules sit ahead of ufw’s.
- Running the framework’s development server in production. Django’s and Flask’s own docs say it isn’t built for production security, stability or efficiency, and it prints a warning you’ve learned to ignore.
- Storing database files inside the container instead of a named volume. A rebuild then deletes your data, and it’s a quiet deletion.
- Tagging everything
latest, which makes “put yesterday’s version back” a conversation with your shell history rather than a one-line edit. - Forgetting that each worker process holds its own memory. Three Gunicorn workers plus Postgres on the cheapest tier is how boxes get killed, as I get into in why cheap VPS boxes keep dying.
- Never reading container logs until something breaks, at which point the useful lines have already rotated out of the default buffer.
FAQ
is docker overkill for one small app?
It costs a little RAM and a Dockerfile, and buys a written record of every dependency plus the ability to rebuild on a fresh box in minutes. Above the complexity of a static site, that trade is clearly worth it. For plain HTML, skip it and let nginx serve the files.
how do i see inside a running container?
Use docker compose exec web sh for a shell, docker compose logs -f web to follow output, and docker stats to watch memory and CPU per container in real time. Treat anything you change inside that shell as temporary, because the next rebuild throws it away. If a fix needs to survive, it belongs in the Dockerfile.
how much RAM does this need?
The daemon itself is modest. What costs you is the app plus the database, and Postgres wants more than people budget for. A 1 GB box will run a small app and a small database if you keep worker counts low and add swap; 2 GB removes most of the anxiety. Measure with docker stats under real traffic rather than trusting anyone’s estimate, mine included.
Update history: added a .dockerignore so secrets stay out of the image, fixed the health check for the slim Python image, and noted Docker’s own advice on the install script and the docker group.
Sources
- Docker Compose documentation — service definitions, published port syntax, health checks, secrets and volumes.
- nginx proxy module reference — proxy_pass, header forwarding and timeout directives.
- Caddy reverse proxy quick start — the automatic-certificate alternative shown above.
- Install Docker Engine on Ubuntu — the apt-repository install, and why the convenience script isn’t recommended for production.