Deploying Apps with Docker and DigitalOcean


I used to deploy by SSH-ing into a server, pulling git, running npm install, and praying. It worked until it did not — Node version drift, "works on my machine," midnight rollbacks.
Docker did not solve everything. It froze my app environment so deploy meant: build image, push, run container. Repeatable.
This post is the path I still use for side projects: Dockerfile → registry → DigitalOcean droplet. For how I think about backend architecture around this, see how I build scalable web apps.
What: A recipe for the runtime image.
Why: Same layers locally and in production. No surprise missing dependencies.
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
What I also add:
.dockerignore with node_modules, .git, .env.local — smaller context, faster buildsRed flag: Copying .env or secrets into the image. Use droplet env vars or a secrets manager at runtime. Baked secrets in layers are a leak waiting to happen.
What: docker build locally or in CI, then docker push to Docker Hub or DigitalOcean Container Registry.
Why: The server pulls a known artifact. You are not compiling on the droplet under load.
docker build -t my-app:latest .
docker tag my-app:latest youruser/my-app:latest
docker push youruser/my-app:latest
I tag with git SHA for anything I might need to roll back — my-app:abc1234, not only latest.
What: A small VM on DigitalOcean with Docker installed.
Why: Full control, predictable bill, fine for portfolios and early products.
On the droplet:
docker pull youruser/my-app:latest
docker run -d \
--name my-app \
-p 80:3000 \
--restart unless-stopped \
-e NODE_ENV=production \
youruser/my-app:latest
Port mapping: -p 80:3000 means host port 80 → container port 3000. I have lost hours mapping 3000:3000 while nginx already owned 80. Draw the map on paper once.
Red flag: Running as root inside the container without thinking. For hobby projects I keep it simple; for anything serious I use a non-root user in the Dockerfile.
| Check | Why |
|---|---|
| Health endpoint or curl returns 200 | Container is up, not just "running" |
Env vars set on docker run | DB URLs, API keys not in the image |
Logs: docker logs -f my-app | Catch crash loops early |
| Firewall: only 80/443 public | SSH restricted if possible |
| HTTPS via Caddy, nginx, or DO load balancer | Users and cookies expect TLS |
Manual docker run is fine for two apps. After that I wire GitHub Actions: build on push to main, push image, SSH or API deploy to pull and restart.
Why: Same artifact every time. Deploy becomes a button, not a ritual.
.dockerignore; every build uploaded node_modules from my laptop.python.--restart unless-stopped is default for me now.Docker + a small droplet is still my fastest path to "real URL, real users" for side projects. It is not Kubernetes. It does not need to be.
If you outgrow one box, the same image goes to a bigger droplet, a managed app platform, or a proper orchestrator. The habit that matters is immutable deploys: build once, run that artifact everywhere.
Next read if you are building the app inside the container: how I structure scalable web apps.