For a long time I could run the Flutter app. I could run the Dart server. I could start a database. I could test multiplayer locally. That was enough while everything lived on my machine.
But a public build changes the shape of the problem.
Suddenly the project needs a real URL, TLS certificates, a running backend, a database that survives restarts, a static web bundle, a homepage, health checks, version numbers, iOS archives, and one command that can push the whole thing without me reconstructing the release process from terminal history.
This post is about that part: how I containerized the backend, how I split the domains, how I deploy the web demo, and how make deploy-all became the release button for the project.
The Deployment Shape
The current public setup has three hostnames:
aonw.net -> static homepage
demo.aonw.net -> Flutter web demo
api.aonw.net -> Dart API and WebSocket backend
I like this split because each host has a clear job.
The root domain is not the game bundle anymore. It is a lightweight homepage with the logo, the background, and links to the web demo, iOS TestFlight, devlog, and Reddit.
The demo domain serves the Flutter web app.
The API domain runs the Dart server.
At a high level:
flowchart TB
User["Player / Tester"]
subgraph VPS["VPS"]
Caddy["Caddy > TLS + routing"]
Server["Dart server > compiled executable"]
Postgres[("PostgreSQL")]
Home["/srv/homepage > static HTML"]
Demo["/srv/demo > Flutter web bundle"]
end
User -->|https://aonw.net| Caddy
User -->|https://demo.aonw.net| Caddy
User -->|https://api.aonw.net| Caddy
Caddy --> Home
Caddy --> Demo
Caddy --> Server
Server --> PostgresThis is not exotic infrastructure.
That is intentional.
I wanted something I could understand at 1 a.m. when a deploy fails.
It also leaves a path for horizontal scaling later. The homepage and web demo are static, so they can move to CDN/static hosting independently, while the API can grow into multiple Dart server containers behind a load balancer. In the current architecture, live match WebSockets would need sticky sessions and graceful drain during deploys; later, a Redis or NATS fanout layer could reduce that instance affinity while PostgreSQL stays the durable source of truth.
Why Docker
The backend is a Dart server with PostgreSQL behind it. I could run Dart directly on the VPS, install system dependencies manually, configure systemd, and keep the server environment in sync with my local machine.
I did not want that.
Docker gives me a more repeatable unit:
source code -> Docker image -> running container
The server image is built from a multi-stage Dockerfile:
flowchart LR
Source["Source code"]
Builder["dart:stable builder"]
PubGet["dart pub get"]
Compile["dart compile exe"]
Runtime["debian:bookworm-slim"]
Binary["/app/server"]
Source --> Builder
Builder --> PubGet
PubGet --> Compile
Compile --> Runtime
Runtime --> BinaryThe builder stage uses dart:stable, resolves dependencies, and compiles the server into a native executable.
The runtime stage is smaller. It installs only what the running container needs, copies the compiled binary, copies map assets, creates a non-root user, exposes port 8080, and defines a healthcheck.
That keeps the running container closer to a deployment artifact than a development environment.
Sharing the Core With the Server
AONW has a shared pure Dart package: packages/aonw_core.
That package contains game rules, commands, events, protocol DTOs, AI pieces, fog-of-war logic, and serialization contracts. The Flutter client uses it, and the server uses it too.
That matters because the server is authoritative for multiplayer.
I do not want the server to be a thin relay that blindly trusts the client. I want it to understand the same commands and rules.
The container build copies:
packages/aonw_core/
server/
pubspec.yaml
Then it compiles the server with the app version injected:
AONW_APP_VERSION=0.1.0+30
AONW_RELEASE_CHANNEL=ALPHA
That is why the health endpoint can answer with the exact deployed build:
{
"status": "ok",
"service": "aonw_server",
"version": "ALPHA v0.1.0+30",
"appVersion": "0.1.0+30",
"releaseChannel": "ALPHA"
}
This sounds small, but it is one of those details that makes deploys feel less blurry.
If someone reports a bug, I want to know what build they were actually hitting.
Docker Compose as the Local Deployment Language
The VPS runs the backend stack with Docker Compose.
The main services are:
postgres,server,caddy.
In staging/prod, Caddy is the public entry point. It terminates HTTPS and routes traffic either to static files or to the Dart server.
flowchart LR
Internet["Internet"]
Caddy["Caddy"]
API["server:8080"]
DB[("postgres:5432")]
Homepage["/srv/homepage"]
Demo["/srv/demo"]
Internet --> Caddy
Caddy --> API
Caddy --> Homepage
Caddy --> Demo
API --> DBThe compose file also keeps dev and staging close enough that I can run a local stack without inventing a separate deployment language.
That does not mean local and production are identical. They are not. But they use the same nouns: server, postgres, Caddy, profiles, env vars, health checks.
That is enough to reduce mental overhead.
Caddy: One Router, Three Jobs
Caddy handles the domain split:
api.aonw.net -> reverse_proxy server:8080
aonw.net -> file_server /srv/homepage
demo.aonw.net -> file_server /srv/demo
I like Caddy here because the TLS story is boring in a good way. I do not want certificate renewal to be a project. I want it to disappear into the infrastructure unless something breaks.
The API host gets reverse proxy behavior:
sequenceDiagram
participant Client
participant Caddy
participant Server as Dart Server
participant DB as PostgreSQL
Client->>Caddy: HTTPS /matches
Caddy->>Server: HTTP server:8080
Server->>DB: query / persist
DB-->>Server: result
Server-->>Caddy: JSON
Caddy-->>Client: HTTPS responseThe homepage and demo hosts are static.
That means Caddy can serve them directly from bind-mounted directories:
/root/aonw/build/homepage -> /srv/homepage
/root/aonw/build/demo -> /srv/demo
For the Flutter web app, cache headers matter. The HTML shell and service worker should revalidate. Hashed assets can live much longer.
The rule is:
index.html / flutter_service_worker.js -> no-cache
assets / generated JS / WASM files -> long cache
That is the kind of deployment detail that only becomes visible once you deploy a web app a few times and wonder why the old build is still appearing.
The Web Build Is Not Built on the Server
My first instinct was to containerize everything.
Server? Docker.
Postgres? Docker.
Web build? Also Docker?
In theory, yes.
In practice, the staging server is small, and Flutter web compilation is not tiny. Pulling a full Flutter SDK image and compiling the web bundle on the VPS was not the best use of that machine.
So I chose a more practical split:
- the Dart server is built on the VPS as a Docker image,
- the Flutter web bundle is built locally on my development machine,
- the generated
build/web/directory is rsynced to the server, - Caddy serves it as static files.
That looks like this:
sequenceDiagram
participant Local as Local machine
participant Flutter as Flutter tool
participant SSH as SSH / rsync
participant VPS as VPS build/demo
participant Caddy
Local->>Flutter: flutter build web --wasm --release
Flutter-->>Local: build/web
Local->>SSH: rsync --delete build/web
SSH->>VPS: /root/aonw/build/demo
Caddy->>VPS: serve /srv/demoThis is not as pure as “everything builds on the server,” but it is much more practical for the current stage of the project.
A solo project does not need infrastructure purity. It needs a release process that actually survives contact with reality.
The Static Homepage
The root domain used to be the natural place for the web app.
Then I changed my mind.
I wanted aonw.net to become the project’s front door, not just the current demo build. Today it links to:
- demo web,
- iOS TestFlight,
- devlog,
- Reddit.
Later it can link to more builds, docs, community pages, or source code.
The homepage is deliberately static. It uses the main menu background and the logo, but it does not depend on Flutter.
Deployment is simple:
flowchart LR
Source["deploy/homepage/index.html"]
Assets["logo + background + favicon"]
Stage["build/homepage"]
Upload["rsync"]
Host["aonw.net"]
Source --> Stage
Assets --> Stage
Stage --> Upload
Upload --> HostThis is one of those decisions that makes the whole setup easier to explain:
aonw.net is the homepage.
demo.aonw.net is the playable web build.
api.aonw.net is the backend.
Good URLs remove confusion.
The Release Button
Once the pieces existed, I needed the release process to stop being a checklist in my head.
That became:
make deploy-all
This target does the full release loop:
- verify that I am on
mainand the checkout is clean, - bump the build number,
- commit the version change,
- create an Xcode Organizer archive if possible,
- push
main, - SSH into the server and run the server deploy,
- build and upload the static homepage,
- build and upload the Flutter web demo,
- run health checks for API, demo, and homepage.
As a diagram:
sequenceDiagram
participant Dev as Local repo
participant Git as GitHub
participant Xcode
participant VPS
participant Caddy
Dev->>Dev: preflight clean main
Dev->>Dev: bump version
Dev->>Xcode: archive iOS build
Dev->>Git: push main
Dev->>VPS: ssh make deploy
VPS->>VPS: pull + build server image
VPS->>VPS: restart server container
VPS-->>Dev: API health ok
Dev->>VPS: rsync homepage
Dev->>VPS: rsync web demo
Dev->>Caddy: health checksThis is the real value of the Makefile.
Not that make is special. The value is that I do not need to remember the order while I am tired, excited, or debugging something unrelated.
The deploy target is also intentionally strict. It refuses to run if tracked files are dirty. That protects me from accidentally deploying half-finished local changes.
Version Numbers Are Part of Deployment
The build number lives in more than one place:
pubspec.yaml,- iOS
CURRENT_PROJECT_VERSION, - the compiled server build metadata,
- the deployed health endpoint.
That can become messy quickly.
So make bump-version updates the Flutter version and the iOS project version together, verifies the result, stages the files, and creates a commit like:
Prepare build 30
The server Docker build reads the app version and compiles it into the executable.
The iOS archive receives the same build name and number.
The web build includes the same app version through the Flutter build output.
The release is still not perfect, but at least the build number stops being folklore.
Health Checks
Every deploy needs a boring final question:
Did the thing come back?
For AONW, the release checks:
https://api.aonw.net/health
https://demo.aonw.net/
https://aonw.net/
The API health check returns JSON with the version. The homepage and demo checks return HTTP 200.
That is not a complete test suite. It does not prove that a save can survive 100 turns or that a web browser can play smoothly on every machine.
But it does catch the obvious deployment failures:
- server did not start,
- Caddy is not routing,
- files were not uploaded,
- DNS/HTTPS is wrong,
- the wrong build is running.
For a solo project, this level of automation is already a big improvement over “open a browser and hope.”
Pruning Old Builds
After a few deploys, Docker images and build cache can quietly eat a small VPS.
The deploy flow includes pruning behavior after the server deployment. I can also run a more aggressive clean deploy when needed:
make deploy-clean
This rebuilds without cache and prunes build cache more aggressively.
It is not a glamorous feature, but it matters. Small servers do not fail only because code is wrong. They fail because disks fill, caches grow, logs expand, and nobody notices until the next deploy.
Infrastructure work is often just noticing the boring failure modes early enough.
What I Did Not Automate Yet
There are still pieces I want to improve.
The current deployment is good enough for this stage, but not final.
I still want:
- better load testing numbers,
- better browser smoke tests after web deploy,
- automated backup verification,
- clearer migration safety around future schema changes,
- maybe a cleaner split between staging and production later,
- more observability around active matches and WebSocket subscribers,
- and eventually a release process that can publish mobile builds more smoothly.
I also know that the web build being produced locally is a compromise.
If the project grows, I may move that into a larger CI runner or a dedicated build machine. For now, local build plus rsync is the right amount of machinery.
The Lesson
The hardest part of deployment was not Docker.
It was deciding what should be boring.
The server should be a compiled binary in a container.
PostgreSQL should own persistence.
Caddy should own TLS and routing.
The web app should be static files.
The homepage should be static HTML.
The release should be one command.
The health checks should be automatic.
flowchart TB
Source["Source"]
Version["Build number"]
ServerImage["Server image"]
IOS["iOS archive"]
Homepage["Homepage bundle"]
Web["Flutter web bundle"]
Deploy["deploy-all"]
Health["Health checks"]
Source --> Version
Version --> ServerImage
Version --> IOS
Source --> Homepage
Source --> Web
ServerImage --> Deploy
IOS --> Deploy
Homepage --> Deploy
Web --> Deploy
Deploy --> HealthAONW is still early. The game is unstable. The web version is experimental. The backend has not been stress tested at public scale yet.
But the project now has a real deployment path.
That changes the feeling of the work.
Before, I was building a game on my machine.
Now, I am building something people can actually open, test, break, and respond to.
That is when infrastructure stops feeling like a side quest and starts feeling like part of the game.
Leave a Reply