# Deploy a containerized web application

Source: https://docs.quake.ai/docs/quickstart/deploy-containerized-app
Markdown: https://docs.quake.ai/docs/quickstart/deploy-containerized-app.md

---

# Deploy a containerized web application

In this tutorial, we deploy a containerized web application to Quake AI. By the end, you will have a running Docker container serving a web page on a cloud instance, accessible from the internet through the instance's `PublicEphemeral` public IP.

**What you will learn:**

- How to install Docker on a Quake AI instance
- How to write a simple Dockerfile for a static website
- How to build and run a Docker container on a remote server
- How to open HTTP ports in a security group for web traffic
- How to verify your application is accessible from the internet

**Time estimate:** 30 minutes



**Configure swap** ([see below](#step-3a-configure-swap-developer-plan-and-other-low-ram-tiers)) and set explicit container memory limits before you deploy. Docker uses ~100–150 MB RAM on top of the OS, leaving ~650 MB for containers on the Developer Plan's 1 GiB. For workloads that need multiple containers running concurrently, see [Resource tiers](/docs/account/resource-tiers) for larger packs (OpenClaw Starter includes 4 GiB RAM; Basic includes 16 GiB).



<Figure size="md" caption="What you'll build: a Docker container running on an Ubuntu instance, exposed to the internet through a security group and its PublicEphemeral public IP">

```d2
direction: right

user: Visitor {shape: person}
pip: Public IP\n(PublicEphemeral)
sg: Security group\nSSH + HTTP
instance: Ubuntu instance {
  docker: Docker engine
  app: Web container\n(Dockerfile build)
  docker -> app: runs
}

user -> pip: HTTP
pip -> sg
sg -> instance.app: port mapping
```

</Figure>


<PricingCompanion
  title="Entry-tier footprint"
  components={[
    { kind: "primitive", required: true, label: "Ubuntu instance (Docker host)", vm: { flavor: "s1a.micro" } },
  ]}
/>
## Prerequisites

You need:

- Completed the [Launch your first server](/docs/quickstart/launch-your-first-server) tutorial
- SSH access to your instance (or create a new one following that tutorial)
- Basic familiarity with the command line

This tutorial assumes Ubuntu 24.04 on the instance reachable over its `PublicEphemeral` public IP, which the network assigns at boot with no floating IP add-on. We refer to that address as `YOUR_INSTANCE_IP` throughout.

## Step 1: Allow SSH and HTTP, then launch the instance

We need inbound TCP **22** for administration and **80** for HTTP. Security groups enforce those rules at the network edge before traffic reaches the instance.

**If you are creating a new instance:**

1. Go to **Network** > **Security Groups** > **Create Security Group**. Name it `tutorial-web` and add a short description.
2. Add an **ingress** rule for **SSH** (port 22) from a CIDR you trust. For learning, `0.0.0.0/0` is common; tighten this in production.
3. Add an **ingress** rule for **HTTP** (port 80) from `0.0.0.0/0` so browsers and health checks can reach nginx.
4. Launch an instance at **Compute** > **Instances** > **Create Instance**:
   - **Image:** `Ubuntu-24.04`
   - **Flavor:** `s1a.micro` (1 vCPU, 1 GiB RAM), the entry-tier footprint
   - **Network:** attach `PublicEphemeral`, which assigns a public IP at boot
   - **Security groups:** include `default` and `tutorial-web` (or equivalent) so both platform defaults and your SSH/HTTP rules apply
   - **Key pair:** the same key you use for SSH
5. Copy the public IP the instance shows once it is **Active**. Note it as `YOUR_INSTANCE_IP`.

**If you reuse the instance from Deploy your first server:**

1. Edit your security group (or create `tutorial-web` as above) and add an ingress rule for **HTTP** on port 80.
2. Attach the updated group to the instance if it is not already applied.
3. Use the instance's existing `PublicEphemeral` public IP as `YOUR_INSTANCE_IP`.

Wait until the instance shows **Active** before continuing.

## Step 2: SSH into the instance

We connect over SSH because Docker commands run on the server, not on your laptop. Replace `YOUR_INSTANCE_IP` with the address you recorded.

```bash
ssh ubuntu@YOUR_INSTANCE_IP
```

Accept the host key prompt on first connect. If your image uses a different default user, substitute that username.

## Step 3: install Docker

Docker packages your app and its runtime together so nginx runs the same way on every host. We use the official Docker apt repository so updates track Docker's supported packages on Ubuntu 24.04.

If you are already logged in from Step 2, run the install and group commands below. The first block repeats the SSH line for anyone joining mid-tutorial.

<InstallDocker username="ubuntu" method="official" />

Confirm Docker can reach its daemon:

```bash
docker run --rm hello-world
```

Docker pulls a small test image and prints a message that starts with `Hello from Docker!`, confirming the daemon is reachable from your user account.

### Step 3a: Configure swap (Developer Plan and other low-RAM tiers)

If you are on the Developer Plan or any flavor with 2 GiB or less of RAM, add 1 GiB of swap before running containers. This prevents out-of-memory kills when a container spikes:

```bash
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h
```

The output of `free -h` should show 1 GiB of swap. Skip this step if you launched a flavor with 4 GiB or more.

## Step 4: Create the project files

We keep a small static site so we focus on containers, not application frameworks. nginx serves `index.html` on port 80 inside the container.

```bash
mkdir -p ~/container-demo && cd ~/container-demo
```

Create `index.html`:

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Quake AI container demo</title>
  </head>
  <body>
    <h1>Hello from Docker on Quake AI</h1>
    <p>If you see this page, your container and security group are configured correctly.</p>
  </body>
</html>
```

Create `Dockerfile`:

```dockerfile
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
```

`nginx:alpine` is a small image with a production-grade web server, which keeps pull and build times short.

## Step 5: Build the image

The build reads the Dockerfile and produces a local image tag we can run.

```bash
docker build -t YOUR_IMAGE_NAME .
```

Use a meaningful tag (for example `rumble-static-demo:latest`) in place of `YOUR_IMAGE_NAME`.

## Step 6: Run the container

We publish container port 80 to the host's port 80 so traffic that reaches the VM on port 80 (allowed by the security group) reaches nginx.

```bash
docker run -d --name YOUR_CONTAINER_NAME -p 80:80 YOUR_IMAGE_NAME
```

Replace `YOUR_CONTAINER_NAME` with a unique name such as `static-web`.



On the Developer Plan, always pass `--memory` and `--memory-swap` so a runaway container cannot consume all available RAM:

```bash
docker run -d --name YOUR_CONTAINER_NAME \
  --memory=256m --memory-swap=512m \
  -p 80:80 YOUR_IMAGE_NAME
```

Use Alpine-based images (`nginx:alpine`, `node:22-alpine`, `python:3.12-alpine`). They are 5–10× smaller than full images and pull faster.



## Step 7: Test locally on the instance

`curl` checks the stack from the server itself before we involve the public network path.

```bash
curl -sS http://127.0.0.1/ | head -n 5
```

You should see the HTML title or heading from `index.html`. If the command hangs or fails, run `docker ps` and `docker logs YOUR_CONTAINER_NAME` to confirm the container is running.

## Step 8: Reach the app from the internet

From your **local** terminal (not inside SSH), request the instance public IP:

```bash
curl -sS http://YOUR_INSTANCE_IP/ | head -n 5
```

If this fails but Step 7 passed, verify the security group allows ingress on port 80 (and that no host firewall blocks port 80).

## Step 9: Verify in a browser

Open `http://YOUR_INSTANCE_IP` in a desktop or mobile browser. You should see the demo heading and paragraph. Browsers follow redirects and cache behavior differently than `curl`, so this step confirms real user traffic works.

## Multi-service stacks with Docker Compose (optional)

Once your single-container app works, you can compose multiple services. Create `~/compose-demo/compose.yaml` with explicit memory limits:

```bash
mkdir -p ~/compose-demo && cd ~/compose-demo
cat > compose.yaml <<'EOF'
services:
  app:
    image: node:22-alpine
    working_dir: /app
    volumes:
      - ./app:/app
    ports:
      - "3000:3000"
    command: ["node", "server.js"]
    deploy:
      resources:
        limits:
          memory: 256M

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    deploy:
      resources:
        limits:
          memory: 64M
EOF
```

Add a minimal Node.js entry point:

```bash
mkdir -p app
cat > app/server.js <<'EOF'
const http = require("http");
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ status: "ok", container: true }));
});
server.listen(3000, () => console.log("Listening on :3000"));
EOF
```

Bring the stack up:

```bash
docker compose up -d
docker compose ps
curl http://localhost:3000
```

On the Developer Plan, `docker stats` shows real-time memory consumption per container, useful when tuning limits.

## Resource management tips

On any small tier, memory is the primary constraint. Apply these practices:

- **Always set `--memory` limits** on containers to prevent runaway usage.
- **Use Alpine-based images** when available. They are 5–10× smaller than full images.
- **Run one service at a time** during development instead of a full stack.
- **Monitor usage** with `docker stats` to watch real-time memory per container.
- **Clean up on a schedule**: stopped containers and unused images consume disk:

```bash
docker system prune -f
docker image prune -a -f
```

## Cloud-init for zero-touch Docker setup (optional)

To launch a VM with Docker and swap pre-configured, pass this cloud-init script as `--user-data` on `openstack server create`:

```yaml
#cloud-config
package_update: true

write_files:
  - path: /etc/sysctl.d/99-swap.conf
    content: |
      vm.swappiness=60

runcmd:
  - fallocate -l 1G /swapfile
  - chmod 600 /swapfile
  - mkswap /swapfile
  - swapon /swapfile
  - echo '/swapfile none swap sw 0 0' >> /etc/fstab
  - apt-get update
  - apt-get install -y ca-certificates curl
  - install -m 0755 -d /etc/apt/keyrings
  - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
  - chmod a+r /etc/apt/keyrings/docker.asc
  - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
  - apt-get update
  - apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  - usermod -aG docker ubuntu
```

Cloud-init runs at first boot, so the instance reaches `ACTIVE` with Docker already installed.

## What you learned

In this tutorial, we:

- **Configured security groups** so SSH and HTTP reach the instance through explicit rules
- **Installed Docker** using the official repository on Ubuntu 24.04
- **Built an image** from a Dockerfile that layers static content on `nginx:alpine`
- **Ran a container** with port publishing so host port 80 maps to the web server
- **Validated** the deployment with `curl` and a browser through `YOUR_INSTANCE_IP`

## Next steps

- [How to point a domain at a Quake AI resource](/docs/network/how-to/point-domain-to-quake-ai): replace the instance public IP in the browser bar with your own hostname
- [How to issue and auto-renew a TLS certificate with Let's Encrypt](/docs/network/how-to/lets-encrypt-certificate): HTTPS for the container on port 80
- [How to deploy an application to a Quake AI VM from CI](/docs/automation/how-to/app-cicd-vm): automate image build and deploy from GitHub Actions or GitLab CI
- [How to use a container registry with Quake AI](/docs/kubernetes/how-to/use-container-registry): push images to GHCR, Docker Hub, or another registry the VM can pull from
- [How to store application secrets and inject them at runtime](/docs/security/how-to/inject-app-secrets): registry tokens and app config without baking secrets into images
- Read [Docker overview](https://docs.docker.com/get-started/overview/) and [Dockerfile reference](https://docs.docker.com/reference/dockerfile/) for deeper container workflows
- Explore Quake AI compute concepts: [Instances](/docs/compute/concepts/instances), [Images](/docs/compute/concepts/images), and [Flavors](/docs/compute/concepts/flavors)
- Harden exposure with [Security groups](/docs/network/concepts/security-groups) and the [Security hardening checklist](/docs/security/hardening-checklist)
- [Resource tiers](/docs/account/resource-tiers): plan your upgrade path if your stack outgrows the Developer Plan

## Clean up

When you no longer need the demo:

1. **On the instance (SSH):** stop and remove the container:

```bash
docker stop YOUR_CONTAINER_NAME
docker rm YOUR_CONTAINER_NAME
```

2. **In the console:** delete the instance at **Compute** > **Instances** to stop compute charges. The `PublicEphemeral` public IP is released with the instance, so there is no floating IP to release.
3. **Optional:** remove the `tutorial-web` security group if you created it for this walkthrough.
