The beauty of simplicity has become my north star for hosting web applications. Despite working with complex cloud architectures professionally, I've chosen a straightforward Docker stack for my personal projects. As a solo maintainer, I need something manageable without breaking the bank.
Docker hits that sweet spot between complexity and simplicity, providing enough structure without the overhead of enterprise cloud solutions. My personal projects don't need Kubernetes clusters or managed databases - just reliable, affordable infrastructure.
The Old Ways: Docker Compose for the Basics
flowchart LR
web(Web Request)
subgraph **VPS**
subgraph docker_compose.yml
rp[Reverse Proxy]
codemade_app[codemade.net]
service_x[Wordpress 1]
service_y[Postgres]
service_z[Email forwarder]
end
end
web -- web --> rp --> codemade_app
rp -->service_x --> service_y
web -- email --> service_z
Initially, my hosting needs were modest - a mail forwarder and a couple of WordPress sites. For this small collection, a simple docker compose setup was adequate. I'd SSH into my server, run commands, and everything would be up and running with minimal fuss.
This hands-on approach worked perfectly - make a change, connect to the server, pull the latest version, and restart containers. Since updates were infrequent and downtime wasn't critical, this manual method suited my needs. My docker compose file was straightforward, defining just the necessary services with basic volume mounts and simple network configurations.
The only "fancy" element was this website, which I've since moved to 11ty and GitHub Pages.
And then I built a web app
flowchart LR
web(Web Request)
web --> caddy[/Reverse Proxy/]
app
redis
subgraph **VPS**
caddy
subgraph Stats Stack
app[Stats App]
redis[(Stats DB)]
end
end
caddy -.-> app
app <-.-> redis
Recently, I built a web application that required a more robust solution. It was time to graduate from docker compose to something more sophisticated.
Docker Swarm was the perfect choice as a step up from docker compose without going full Kubernetes-level complexity.
I first heard about this approach from the DreamsOfCode YouTube channel, where they called it docker stack. It's technically Docker Swarm with one node, offering a pragmatic middle ground between my simple setup and complex orchestration tools.
The stack deployment files look almost identical to docker compose files, with just a few additional orchestration-specific options. I took my existing compose file, added deployment constraints and update policies, and it was ready - no steep learning curve required.
I host it on a single but capable VPS on DigitalOcean, which is more than enough for my needs.
The Automated Pipeline
Here is my current workflow:
- Push code changes to GitHub
- GitHub Actions builds a container image and publishes it to GitHub Container Registry
- A deployment action (
shockhs/docker-stack-deploy@v1.2) automatically updates my Swarm stack - The new version rolls out with zero downtime
You can take a look at a simplified version of my GitHub Actions workflow if you're curious.
Handling SSL with Caddy
For SSL and routing, I use the excellent Caddy as a reverse proxy. It automatically handles certificate generation and renewal from Let's Encrypt, which is a huge time-saver.
Caddy runs as a separate standalone container outside the Swarm cluster. This was a deliberate architectural decision - running the reverse proxy inside the Swarm would mean losing the originating IP addresses of incoming requests due to how networking works in Swarm mode.
By keeping Caddy separate, I maintain visibility of client IPs for proper logging and security monitoring. It also means my reverse proxy isn't tied to the lifecycle of my applications. It gets its own repo, and its own deployment pipeline.
The Results
This setup strikes the perfect balance:
- Automated: No manual SSH sessions for routine deployments
- Reliable: Rolling updates ensure zero-downtime deployments
- Maintainable: Everything is defined in code and version controlled
- Affordable: More cost-effective than equivalent managed cloud services
And then there were more
flowchart LR
web(Web Request)
web --> caddy[/Reverse Proxy/]
app
redis
subgraph **VPS**
caddy
subgraph Stats Stack
app[Stats App]
redis[(Stats DB)]
end
subgraph Games Stack
gapp[Games App]
gdb[(Games DB)]
end
end
caddy -.-> app
app <-.-> redis
caddy -.-> gapp
gapp <-.-> gdb
Docker Swarm excels at scaling to multiple applications. Adding a new web app was straightforward - I created a new stack definition file and deployed it to the same swarm.
This provides independent deployment lifecycles for each application. I can update, restart, or delete one app without affecting others. Each stack gets its own isolated network by default, though they can communicate with each other if needed through overlay networks.
Looking forward
flowchart LR
web(Web Request)
web --> caddy[/Reverse Proxy/]
subgraph VPS4
subgraph Stats Node / 3
app4[Stats App]
redis4[(Stats DB)]
end
end
subgraph VPS3
subgraph Stats Node / 2
app3[Stats App]
redis3[(Stats DB)]
end
end
subgraph VPS2
subgraph Stats Node / 1
app2[Stats App]
app21[Stats App]
app22[Stats App]
redis2[(Stats DB)]
end
end
subgraph **VPS 1**
caddy --> other[other serivces]
end
caddy -.-> app2
app2 -.-> redis2
app21 -.-> redis2
app22 -.-> redis2
caddy -.-> app3
app3 -.-> redis3
caddy -.-> app4
app4 -.-> redis4
style other stroke-dasharray: 5, 5;
style VPS2 fill:#fff4,stroke-dasharray: 5, 5;
style VPS3 fill:#fff4,stroke-dasharray: 5, 5;
style VPS4 fill:#fff4,stroke-dasharray: 5, 5;
Should one of my services become a runaway success, I can move it to its own VPS and scale independently, either by increasing VPS size or adding replicas to the swarm stack. For further growth, adding more VPSs to the same swarm would be the next step.
But that's a problem for another day! 😎