[AoNW] Containerizing and Deploying

Written by

in

, ,

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 --> Postgres

This 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 --> Binary

The 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 --> DB

The 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 response

The 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/demo

This 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 --> Host

This 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:

  1. verify that I am on main and the checkout is clean,
  2. bump the build number,
  3. commit the version change,
  4. create an Xcode Organizer archive if possible,
  5. push main,
  6. SSH into the server and run the server deploy,
  7. build and upload the static homepage,
  8. build and upload the Flutter web demo,
  9. 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 checks

This 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 --> Health

AONW 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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *