AboutBlogContact
DevOps & InfrastructureSeptember 11, 2014 10 min read 179Updated: June 22, 2026

Before Docker: How We Actually Deployed Applications in 2012-2014

AunimedaAunimeda
📋 Table of Contents

Docker was released in March 2013. It became widely used in production around 2015-2016. Before that - and for most small teams well into 2014 - deploying a web application meant something entirely different.

This is what deployment looked like in the pre-container era, why it was painful, and what we actually did to make it manageable.


The shared hosting era (for context)

For small projects in 2008-2011, "deployment" meant:

  1. Open FileZilla
  2. FTP files to the server
  3. Hope nothing broke

cPanel gave you a file manager. You could edit files directly on the server through the browser. This was considered acceptable. Many teams did production debugging by editing files live on the server.

The problems were obvious in hindsight: no version control for deployments, no rollback, no parity between local development and production, no way to reproduce "works on my machine" bugs.


The VPS era: SSH and scripts

As PHP applications got more complex, shared hosting became inadequate. Teams moved to VPS (Virtual Private Server) - a virtual machine you controlled entirely, typically running Ubuntu or CentOS.

Deployment on a self-managed VPS in 2011:

# ssh into server
ssh user@192.168.1.100

# pull latest code
cd /var/www/myapp
git pull origin main

# install/update PHP dependencies
php composer.phar install --no-dev

# run database migrations
php artisan migrate --force

# clear caches
php artisan cache:clear
php artisan config:clear

# reload PHP-FPM so new code is picked up
sudo service php5-fpm reload

# hope nothing broke

For Node.js apps:

ssh user@server
cd /var/www/myapp
git pull origin main
npm install --production
# manually kill and restart the process
kill $(cat tmp/pids/server.pid)
node server.js &
echo $! > tmp/pids/server.pid

This was manual, error-prone, and impossible to undo cleanly if something went wrong. "Rollback" meant git reset --hard and hoping the database migration was reversible.


Capistrano: the first real deployment tool

Capistrano (Ruby) became the gold standard for PHP and Ruby deployment around 2010-2012. The core concept: deploy to a timestamped directory, symlink current to the new release, keep the last N releases for rollback.

/var/www/myapp/
├── current -> releases/20131205143021
├── releases/
│   ├── 20131205143021/    ← current deploy
│   ├── 20131202091545/    ← previous deploy
│   └── 20131128174312/    ← older deploy (kept for rollback)
└── shared/
    ├── .env               ← config not in git
    ├── logs/
    └── uploads/           ← user-uploaded files

A basic Capfile:

set :application, "myapp"
set :repo_url, "git@github.com:mycompany/myapp.git"
set :deploy_to, "/var/www/myapp"
set :branch, "main"

set :linked_files, %w(.env)
set :linked_dirs, %w(uploads logs)

namespace :deploy do
  after :publishing, :restart do
    on roles(:web) do
      execute :sudo, 'service php5-fpm reload'
    end
  end
  
  after :finishing, 'deploy:cleanup'
end

Running cap production deploy would:

  1. SSH to production servers
  2. Create a new timestamped release directory
  3. Run git archive to extract the current code
  4. Run composer install
  5. Link shared files and directories
  6. Run migrations
  7. Symlink current to new release
  8. Reload PHP-FPM
  9. Remove old releases (keeping 5 by default)

If something went wrong: cap production deploy:rollback - symlink current back to the previous release. Zero-downtime rollback in 5 seconds.

This was a massive improvement over manual SSH deploys. But it still had problems.


The configuration drift problem

The deeper issue wasn't the deploy script. It was keeping servers consistent.

You have three web servers. You SSH into each one individually to install a package. Two months later, a junior developer installs a slightly different version of a dependency on one of them. Three months later, a bug appears in production that nobody can reproduce locally or on the other servers. Eventually you trace it to the version difference. This is configuration drift.

The standard 2012-era solution was to never install anything by hand. Instead, use a configuration management tool:

Chef (Ruby-based): You wrote "recipes" that described the desired state of a server. Chef would connect to all your servers and apply the recipes, ensuring they all had exactly the same configuration.

# Chef recipe: install nginx, create config, ensure it's running
package 'nginx' do
  action :install
end

template '/etc/nginx/sites-available/myapp' do
  source 'nginx.conf.erb'
  variables(
    server_name: node['myapp']['server_name'],
    app_port: node['myapp']['port']
  )
  notifies :reload, 'service[nginx]'
end

service 'nginx' do
  action [:enable, :start]
end

Puppet (similar to Chef, slightly different syntax):

package { 'nginx':
  ensure => installed,
}

service { 'nginx':
  ensure  => running,
  enable  => true,
  require => Package['nginx'],
}

file { '/etc/nginx/sites-available/myapp':
  content => template('nginx/myapp.conf.erb'),
  notify  => Service['nginx'],
}

Ansible (Python-based, agentless, arrived 2012) - simpler than Chef/Puppet, no agent required on servers, uses YAML:

- name: Deploy web application
  hosts: web_servers
  
  tasks:
    - name: Install nginx
      apt:
        name: nginx
        state: present
    
    - name: Copy nginx config
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/sites-available/myapp
      notify: Reload nginx
    
    - name: Pull latest code
      git:
        repo: "git@github.com:mycompany/myapp.git"
        dest: /var/www/myapp
        version: "{{ branch | default('main') }}"
  
  handlers:
    - name: Reload nginx
      service:
        name: nginx
        state: reloaded

Running ansible-playbook deploy.yml -e "branch=v1.2.3" would deploy to all web servers simultaneously, in a known-good state.


Environment management: the constant pain

The most common source of deployment failures was environment differences. An application that worked perfectly in development would fail in production because:

  • Local machine: PHP 5.3.10, Production: PHP 5.3.3 (minor version difference, different behavior)
  • Local machine: MySQL 5.5, Production: MySQL 5.1 (different handling of certain queries)
  • Local machine: Ubuntu 12.04, Production: Ubuntu 10.04 (different package versions)
  • Local machine had a config value set; production environment variable wasn't set

.env files became the standard for configuration:

# .env (not committed to git)
DB_HOST=localhost
DB_NAME=myapp
DB_USER=appuser
DB_PASS=secretpassword
REDIS_URL=redis://localhost:6379
MAIL_HOST=smtp.mailgun.org
MAIL_USER=postmaster@myapp.com
MAIL_PASS=mailpassword
APP_ENV=production
APP_DEBUG=false

The .env file lived on the server, symlinked into the deploy directory by Capistrano. This kept secrets out of version control and allowed different values per environment.

But .env files were manually managed. Deploying a new required environment variable meant:

  1. Updating .env.example in the repo (for documentation)
  2. SSHing into each server and adding the variable to .env
  3. Restarting the application

Missing step 2 caused deployments to fail in production with cryptic errors like "Environment variable APP_SECRET is not set."


The staging environment struggle

The closest pre-Docker solution to "production parity" was a staging environment - a separate server that was supposed to be identical to production. You deployed to staging first, tested, then deployed to production.

In practice, staging was never truly identical to production. It had:

  • Different hardware (smaller instance to save costs)
  • Different amounts of data (a subset of production data)
  • Configuration values that were almost-but-not-exactly production
  • Software versions that had drifted from production

"It works on staging" wasn't a guarantee. "It works on staging and staging was last updated six months ago" was actively misleading.


Process management: keeping Node.js alive

PHP-FPM managed PHP process lifecycle. For Node.js, which ran as a persistent process, you needed something to restart it when it crashed and ensure it started on server reboot.

Forever (2011): The first widely-used Node.js process manager.

# Install globally
npm install -g forever

# Start application
forever start server.js

# List running processes
forever list

# Stop application
forever stop server.js

# Restart
forever restart server.js

# View logs
forever logs server.js

PM2 (2013): Replaced Forever as the standard. Better monitoring, cluster mode (multiple processes using all CPU cores), log rotation, and a monitoring dashboard.

# Start with PM2
pm2 start server.js --name "myapp" --instances max

# ecosystem.config.js (the "Docker Compose" of the PM2 world)
module.exports = {
  apps: [{
    name: 'myapp',
    script: 'server.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'development'
    },
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
};

# Deploy to production
pm2 start ecosystem.config.js --env production

# Save process list so it survives server reboot
pm2 save
pm2 startup  # generates the startup script for the OS

PM2's cluster mode was genuinely important. Node.js is single-threaded. A server with 8 CPU cores running a single Node.js process used only 1 core. PM2's cluster mode ran 8 processes and load-balanced between them, fully utilizing the hardware.


Zero-downtime deploys

A naive restart - stop the process, start the new version - causes downtime. For high-traffic sites, even 2-3 seconds of downtime during deploys was unacceptable.

The nginx upstream trick:

upstream app {
  server 127.0.0.1:3000;
  server 127.0.0.1:3001;
  server 127.0.0.1:3002;
  server 127.0.0.1:3003;
}

You'd start new processes on different ports, then reload nginx to point to the new processes, then stop the old ones. Manual and error-prone, but it worked.

PM2 cluster mode made this much simpler - pm2 reload myapp would restart workers one by one, with the others continuing to serve traffic. Zero downtime, no nginx config changes.


What Docker actually changed

When Docker appeared in 2013 and became practical in 2015-2016, it solved the problems above in a fundamentally different way:

Image = code + runtime + dependencies + configuration. Not "here are the instructions to set up the server to run the code." Here is the entire thing, packaged, reproducible.

FROM node:18-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

docker build creates an image. docker run starts it identically on any machine with Docker installed. Your laptop, the CI server, staging, production - same image, same behavior.

Configuration drift: eliminated. "Works on my machine": eliminated. Rollback: docker run image:previous-tag. Staging parity: docker pull staging_image && docker run staging_image.

The cognitive load of pre-Docker deployment was enormous. You had to know the OS version, the package manager, the service manager, the process manager, the configuration management tool, and the deploy script. Each was a separate system with its own documentation, failure modes, and version incompatibilities.

Docker collapsed this into one abstraction. One Dockerfile. One build command. One run command.


What we miss about the old way (seriously)

Simplicity of debugging. When something went wrong in production in 2012, you SSH'd in and poked around. tail -f /var/log/nginx/error.log, ps aux, netstat -tlnp. The server was a place you could walk around in.

With containers, you have docker exec -it container_id bash, which is similar, but ephemeral containers designed to be thrown away make this feel different. And Kubernetes adds another abstraction layer where even knowing which container is running your code requires a kubectl command.

Straightforward log management. Before container orchestration, logs lived in files on servers. grep, awk, tail - standard Unix tools worked perfectly. Container logs require log drivers, centralized logging systems, and log aggregators. Better for scale. More overhead for small teams.

Bare metal performance. Containers add layers. For most applications the overhead is negligible. For high-throughput services, the raw performance of a well-tuned process running directly on a server without container overhead is still measurably better.

The pre-Docker era was not a golden age. It was genuinely painful in ways that Docker solved. But the solutions it introduced have their own complexity, and that complexity is real. The right tool in 2026 depends on your scale and your team. A startup with three developers deploying a single Node.js app to one VPS might be better served by a Dockerfile + PM2 than by a full Kubernetes cluster.

The lesson from 2012-2014: understand why the tools that replaced the old way exist. Not just what they do. Understanding why Docker exists makes you better at using it, better at knowing when it's overkill, and better at debugging it when it goes wrong.


Aunimeda provides DevOps engineering and infrastructure services - CI/CD pipelines, containerization, cloud deployments, and monitoring setups.

Contact us to discuss your infrastructure needs. See also: DevOps Services, Custom Software Development

Read Also

Docker Compose vs Kubernetes: What Small Teams Actually Need in 2026aunimeda
DevOps & Infrastructure

Docker Compose vs Kubernetes: What Small Teams Actually Need in 2026

Kubernetes is powerful and over-engineered for most small products. Docker Compose is simple and hits its limits faster than you'd think. Here's where the actual boundary is, with real configs for both.

DevOps for Startups - What You Actually Need (And What to Skip)aunimeda
DevOps & Infrastructure

DevOps for Startups - What You Actually Need (And What to Skip)

Most startup DevOps guides tell you to set up Kubernetes. You don't need Kubernetes. Here's the minimal, effective DevOps setup for a product with under 100k users.

Cloud Hosting Comparison 2026: AWS vs GCP vs Azure vs Hetzner vs Vercelaunimeda
DevOps & Infrastructure

Cloud Hosting Comparison 2026: AWS vs GCP vs Azure vs Hetzner vs Vercel

Which cloud provider to choose for your startup in 2026. Real pricing comparison, performance benchmarks, and the hosting stack that makes sense at each stage.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

DevOps Services

Get Consultation All articles