Containerizing Your DTC Site: Docker Config, Image Optimization, Orchestration, and Resource Limits
1. Why containerize a DTC site instead of installing directly on the server
You can install PHP, Nginx, and MySQL straight on a VPS and it will run. But three problems surface within six months. First, a new hire spends two days setting up the environment. Second, when you want to migrate from Alibaba Cloud to Tencent Cloud, you reinstall every dependency. Third, when a security patch drops, you’re afraid to touch the production version. Docker packages the app and its dependencies into one immutable image, and those are the three problems it solves.
One test: if your deployment doc says “first apt install php8.3-fpm, then install these 12 extensions, then change these 8 php.ini settings,” you need containerization. Put those 12 extensions and 8 settings in a Dockerfile, and from then on any machine can reproduce it with one docker run. For how CDN and static assets work together, see CDN global acceleration.
| Deployment options | Environment setup cost | Migrating servers | Rollback speed | Best for |
|---|---|---|---|---|
| Direct install on VPS | 2 person-days | Reinstall every dependency | Snapshots, 30 minutes | Prototype stage |
| Shell script setup | 0.5 person-days | Run the script, still drift | 15 minutes | Single site, 1 year |
| Docker image | 5 minutes | Pull image and run | Switch image tag, 3 minutes | Production, multi-environment |
| Docker Compose orchestration | 5 minutes | Copy the yaml and run | One-click rollback | App + database + queue |
2. How to write a Dockerfile that isn’t bloated
The most common Dockerfile mistake on DTC sites is single-stage builds: FROM ubuntu, then install PHP, install Composer, composer install, COPY the source in, expose the port. Images built this way usually exceed 1GB because they include build tools, npm cache, and the .git directory. The right approach is a multi-stage build: stage one uses a builder image to compile the frontend and install composer dependencies; stage two copies only the build output and runtime into an alpine image.
A few hard rules: use alpine as the base image, not ubuntu. Your .dockerignore must exclude node_modules, .git, .env, and upload directories. Add –no-dev and –no-cache to npm/composer install. Run as a non-root user. Image size targets: keep a pure PHP/Node app under 200MB, and under 400MB if it ships static assets. For image scanning and security baselines, see Backup and recovery drills for your site.
A real-world case: a team found their image was 1.4GB six months after launch. Digging layer by layer with docker history, they found composer install was missing –no-dev, so phpunit and a pile of dev dependencies had been baked into the production image. COPY . . had also pulled in the .git directory. After switching to a multi-stage build, with dependencies installed in the builder stage and only the vendor and public directories copied into the runtime stage, the image dropped to 180MB and deploy time went from 3 minutes to 40 seconds.
| Tier | Common mistakes | Optimized approach | Size saved |
|---|---|---|---|
| Base image | FROM ubuntu:22.04 | FROM php:8.3-fpm-alpine | About 400MB |
| System dependencies | apt-get install build-essential | Multi-stage build, installed in the builder stage | About 200MB |
| PHP dependencies | composer install | composer install –no-dev –no-cache | About 80MB |
| Frontend dependencies | COPY . . | npm ci && npm run build in the builder stage | About 300MB |
| Source directory | COPY . /var/www | COPY –from=builder /app/public ./public | About 100MB |
| Runtime user | Running as root | RUN adduser -D app | Security gain, no size change |
3. How to orchestrate a standalone site with docker compose
A typical standalone site runs four containers: Nginx as reverse proxy, a PHP-FPM or Node app, MySQL/PostgreSQL, and Redis. Wire the four services together in a single docker-compose.yml, mount a data volume to persist MySQL data, expose only ports 80 and 443 on Nginx, and keep the other services on the internal network. Never expose the MySQL port to the public internet.
A config snippet you can actually use: Nginx depends_on php, php depends_on db and redis; the health check runs curl -fsS http://localhost/health every 30 seconds; the restart policy is unless-stopped across the board. For backups, mount the MySQL data volume to a backup directory on the host on a schedule, then sync it offsite with rsync. For monitoring to go with multi-container orchestration, see Site monitoring and alerts.
Example: a 4-core, 8GB Alibaba Cloud lightweight server running three containers: WordPress, MySQL, and Redis. The MySQL container mounts a named volume for data, Nginx maps ports 80 and 443 to the host, and the WordPress container exposes no ports and stays on the internal network. After a host reboot, docker compose up -d brings all three services back up automatically. Nothing needs to be started by hand after logging in. That is the plainest value of compose.
| Services | Base image | CPU limit | Memory limit | Persistent |
|---|---|---|---|---|
| nginx | nginx:1.27-alpine | 0.5 core | 128MB | No |
| php-fpm / node | php:8.3-fpm-alpine | 1 to 2 cores | 512MB to 1GB | No (mount a volume for uploads) |
| mysql | mysql:8.4 | 1 to 2 cores | 1 to 2GB | Yes (data directory) |
| redis | redis:7-alpine | 0.5 core | 256MB | Yes (AOF persistence) |
| cron/queue | Same as the app image | 0.5 core | 256MB | No |
4. Resource limits: an unbounded container can take down the whole machine
Many people use Docker for convenience and skip –cpus and –memory at startup. The result: one slow query hits PHP-FPM, memory climbs from 512MB to 4GB, and MySQL on the same machine gets squeezed into an OOM kill. Every container must declare resource limits explicitly. This is not optional.
Resource quotas for a standalone site: 1 to 2 CPU cores and 512MB to 1GB memory for the app container; size the MySQL container to your data, and 1 core and 1GB is enough for under 10GB; 0.5 core and 128 to 256MB each for Nginx and Redis. Leave 20% of the host for the Docker daemon and the system. One rule of thumb: allocate 80% of host memory to containers and keep 20% for the system. Go over that ratio and it is time to add a machine. Allocate CPU by peak, not by average. Standalone site traffic peaks on weekdays and drops at night, so sizing CPU by the average means queues at peak.
| Site stage | Recommended setup | CPU allocation | Memory allocation | Monthly visits |
|---|---|---|---|---|
| Early validation | 1 x 2-core 2GB VPS | App 1 core + DB 0.5 core | App 512MB + DB 1GB | <50K |
| Steady growth | 1 x 4-core 8GB VPS | App 2 cores + DB 1.5 cores | App 2GB + DB 4GB | 50K to 300K |
| Multilingual peak | 2 x 4-core 8GB | App 2 cores each | 2GB each, DB 4GB separate | 300K to 1M |
| Independence Day peak | Scale the app out to 3 to 4 machines | 2 cores each | 2GB each | Promo and trade show traffic |
5. When to move to Swarm or K8s
The counterintuitive conclusion: 90% of standalone sites do not need K8s. docker compose runs 4 to 8 containers on a single VPS, which is enough for a B2B site with under 100K daily visits. Only when one of these three signals appears should you consider moving up to Swarm: you need to run the app across two or more VPSs, you need blue-green deploys that shift traffic automatically, or you need health checks that restart dead containers.
The test is simple: if your docker-compose.yml has more than 30 services, or more than 5 people maintain the environment at once, then consider an orchestration system. Otherwise compose is the best answer, and the effort you would spend maintaining K8s pays off more in content and SEO.
The barrier with K8s is that you need someone dedicated to etcd, network plugins and the Ingress controller, and troubleshooting alone eats a day a week. For a small team, that time pays off more in content and SEO. If you really do need multi-instance deployment later, see the load balancing design in Load balancing and multi-instance deployment.
| Orchestration tool | Learning curve | Maintenance cost | Node count | When to choose it |
|---|---|---|---|---|
| docker compose | 1 to 2 days | Extremely low | 1 | First choice for 90% of standalone sites |
| docker swarm | 3 to 5 days | Low | 2 to 10 | Cross-machine orchestration and rolling updates |
| K8s | 2-4 weeks | High | 5 or more | Multiple teams, multiple services, autoscaling |
| Managed K8s from a cloud provider | 1 to 2 weeks | Medium | 10 or more | You already have a dedicated DevOps engineer |
FAQ
Is Docker slower than installing directly on a server for a standalone site?
What’s a reasonable image size?
Can docker compose handle 100,000 daily visits?
How do I handle logs and uploaded files inside containers?
When should I use K8s, and when should I stick with compose?
Will an alpine image be missing dependencies and fail to run?
Final thoughts
For a standalone site, 80% of the value of containerization is environment consistency, and 20% is orchestration. Get the Dockerfile right, shrink the image to 200MB, and manage your four services well in a compose file, and you’ve solved the most painful problem in a standalone site’s tech stack: environment drift. Don’t chase K8s from the start. That’s a whole other full-time job.
Rollout path: Week 1, write a multi-stage Dockerfile and shrink the image from 1GB to 200MB. Week 2, orchestrate Nginx + app + MySQL + Redis with Compose and mount the data volumes properly. Week 3, hook the CI/CD pipeline up to an image registry so pushing code builds the image automatically. Finish these three steps and migrating servers drops from two days to two hours. For how this connects to CDN and monitoring, see CDN global acceleration. In day-to-day operations, run docker system df once a week to clear dangling images, and your disk won’t slowly fill up with images you no longer use.
One line worth remembering: the first step in containerizing isn’t moving to K8s, it’s getting to the point where you can run your site on any clean server with a single command.
