A sample application that uses Floci to stand in for three AWS services — RDS, ElastiCache and OpenSearch — running on Okteto.
Floci Flix is a small movie catalogue. Each service does the job it actually exists for:
| Service | Backed by | Used for |
|---|---|---|
| RDS | PostgreSQL 16 | The authoritative movies table. Writes are transactional. |
| OpenSearch | OpenSearch 2.11 | Full-text and fuzzy search, with genre facets. |
| ElastiCache | Valkey 8 | Cache-aside on the detail read path, plus a sorted set of view counts. |
The UI has a fourth page, Emulator, showing what each AWS control-plane call returned and where the app actually connected. If you only look at one page, look at that one.
Floci does not mock these services. It starts a real PostgreSQL, a real Valkey and a real OpenSearch as containers, through a Docker daemon.
On a laptop that daemon is the host's. Kubernetes has no socket to share, so in a cluster Floci brings its own: Docker-in-Docker, in a privileged pod. That one requirement shapes everything below.
The application is defined in docker-compose.yml. Floci is not — and that is
the only reason k8s/floci.yaml exists.
Okteto's Compose support does not allow declaring a privileged container:
privileged is parsed only to warn that it is being ignored. Docker-in-Docker
needs it, so Floci has to come out of Compose and into a manifest that Okteto
applies as-is:
deploy:
commands:
- name: Floci (privileged DinD - not expressible in compose)
command: kubectl apply -n ${OKTETO_NAMESPACE} -f k8s/floci.yaml
compose: docker-compose.ymlFloci itself runs as a container inside the DinD pod. That is forced too: Floci resolves the OpenSearch node by Docker container name, and only a container attached to the network can use Docker's embedded resolver.
flowchart TB
APP["api pod<br/>Go application"]
SVC["Service floci (ClusterIP)<br/>4566, 7001-7003, 6379-6381, 9400-9402"]
subgraph POD["floci-0 — a single privileged container (docker:28-dind)"]
subgraph NET["docker network floci-net"]
FLOCI["Floci<br/>4566 AWS API<br/>7001 RDS proxy<br/>6379 ElastiCache proxy"]
PG[("PostgreSQL<br/>publishes nothing")]
VK[("Valkey<br/>publishes nothing")]
OS[("OpenSearch<br/>publishes 9400")]
end
end
APP --> SVC
SVC -->|"4566 control plane, 7001 SQL, 6379 RESP"| FLOCI
SVC -->|"9400 REST, bypassing Floci"| OS
FLOCI -->|"by container IP"| PG
FLOCI -->|"by container IP"| VK
FLOCI -.->|"readiness, by container name"| OS
One Service reaches all of it. dockerd runs inside the pod, so every port
it publishes is bound in the pod's own network namespace and lands on the pod
IP. A plain ClusterIP Service therefore reaches Floci's own listeners and the
port the OpenSearch container publishes — even though nothing in the pod spec
declares 9400.
Two ways a service is exposed, and the difference is the thing to remember:
| Listener on the pod | Traffic path | |
|---|---|---|
RDS floci:7001 |
Floci's own TCP proxy | app → Floci → PostgreSQL |
ElastiCache floci:6379 |
Floci's own TCP proxy | app → Floci → Valkey |
OpenSearch floci:9400 |
the OpenSearch container | app → OpenSearch, Floci uninvolved |
PostgreSQL and Valkey publish no ports at all, so they are reachable only through
Floci — which is also where SigV4 and IAM auth are enforced. OpenSearch
publishes its own port, so it is reachable around Floci. That is why
floci:9400 looks like it goes to Floci, but it goes straight to OpenSearch.
okteto context use <your-context>
okteto namespace use <your-namespace>
okteto deployThat applies k8s/floci.yaml, then hands docker-compose.yml to Okteto, which
turns it into the api and web Deployments and Services. okteto endpoints prints
the public URL.
The first deploy is slow: the Floci pod pulls ~2.3 GB of container images into its PVC before it will answer. Later deploys reuse that cache.
For a hot-reload loop on either service:
okteto up api # go run, rebuilt on save
okteto up web # vite dev serverEverything is set in docker-compose.yml as environment variables with
defaults, so a plain okteto deploy works with no arguments. Any of them can be
overridden by an Okteto admin variable, or per deploy:
okteto deploy --var APP_DB_PASSWORD=... --var SEARCH_DOMAIN=...| Variable | Default | What it does |
|---|---|---|
APP_DB_USER |
appuser |
Master user for the RDS instance the app creates |
APP_DB_PASSWORD |
a placeholder | Master password. See the note below |
APP_DB_NAME |
flociflix |
Database name |
FLOCI_ENDPOINT |
http://floci:4566 |
Where the AWS control plane lives |
AWS_REGION |
us-east-1 |
Region reported by Floci |
DB_INSTANCE_ID |
flociflix-db |
RDS instance the app creates and looks up |
CACHE_GROUP_ID |
flociflix-cache |
ElastiCache replication group |
SEARCH_DOMAIN |
flociflix-search |
OpenSearch domain |
OPENSEARCH_ENDPOINT_OVERRIDE |
unset | Forces a specific address. Normally unnecessary — see below |
Floci's own settings (FLOCI_*: hostnames, proxy port ranges, storage mode) live
in k8s/floci.yaml, because that is the manifest that runs it.
The API has no public endpoint. Only web does, and nginx proxies to the API
server-side inside the cluster:
browser -> https://web-<ns>.<cluster>/api/movies public endpoint, web pod
nginx: location /api/ -> proxy_pass http://api:8080
ClusterIP Service "api", private to the namespace
api -> floci:7001 PostgreSQL (through Floci's proxy)
api -> floci:6379 Valkey (through Floci's proxy)
api -> floci:9400 OpenSearch (direct, published port)
The frontend only ever issues relative URLs, so the browser sees a single origin
and there is no CORS involved. Under okteto up web, Vite's dev proxy takes
nginx's place.
In compose terms: api declares a bare "8080", which stays on the cluster's
private network, while web declares "8080:8080" — the mapping is what makes
Okteto publish an endpoint.
At startup the API calls Floci's control plane with the ordinary AWS SDK —
DescribeDBInstances, DescribeReplicationGroups, DescribeDomain — and uses
the endpoints it gets back. That is the point of running an emulator: the SDK
path is real.
DescribeDBInstances -> floci:7001 -> PostgreSQL (proxied by Floci)
DescribeReplicationGroups -> floci:6379 -> Valkey (proxied by Floci)
DescribeDomain -> floci:9400 -> OpenSearch (direct, see below)
FLOCI_HOSTNAME=floci is what makes the first two resolve: Floci advertises
itself by that name, and it matches the Kubernetes Service name.
The API also creates those three resources if they are missing, through the
same SDK, then applies internal/store/schema.sql, loads
internal/store/seed.sql when the table is empty, and rebuilds the OpenSearch
index from whatever PostgreSQL holds. OpenSearch is never seeded directly, so one
path covers both a first run and a restart that replaced the search node while
leaving the database intact.
Floci advertises the OpenSearch endpoint as a Docker container name,
http://floci-opensearch-<domain>:9200, ignoring FLOCI_HOSTNAME. That name
resolves only from inside Floci's Docker network — true for a container on that
network, false for a Kubernetes pod.
So the API checks whether it resolves: if it does, it uses it; if not, it falls
back to the Floci Service on port 9400, where the OpenSearch container
publishes. Nothing to configure per environment —
OPENSEARCH_ENDPOINT_OVERRIDE exists only to force a specific address.
The Emulator page shows the advertised value, the effective one, and which rule was applied.
| Port | Bound by | Carries |
|---|---|---|
| 4566 | Floci | Every AWS API call |
| 7001–7003 | Floci proxy | PostgreSQL wire protocol + IAM auth |
| 6379–6381 | Floci proxy | Valkey RESP + IAM auth |
| 9400–9402 | OpenSearch container | OpenSearch REST, direct |
Kubernetes Services cannot express port ranges, so the ranges are narrowed to
three ports each via FLOCI_SERVICES_*_PROXY_*_PORT and enumerated in the
Service. Both live in k8s/floci.yaml — widen them together.
Two ranges must not be published: 5100–5199 (Floci starts an ECR registry
sidecar that binds them itself) and 9200–9299 (Lambda Runtime API, internal).
Floci runs with FLOCI_STORAGE_MODE=memory, so restarting its pod is a clean
slate: new containers, no data. The API notices, re-creates the three AWS
resources, re-applies the schema, re-seeds the catalogue and rebuilds the search
index. No manual step, and the environment comes back working.
Data does not survive a restart. Treat anything you add through the UI as disposable — Okteto namespaces scale to zero, so this is routine rather than exceptional.
That is deliberate, and it is a property of Floci rather than of this app. A
persistent storage mode restores Floci's resource metadata across a restart
but does not re-create the containers behind it. The control plane then reports
a service as available with nothing listening, which is worse than an empty
database: it looks healthy and hangs. Memory mode trades durability for a
restart that is deterministic.
Two consequences shape the app:
- The API supervises its connections instead of connecting once. Floci replaces containers behind the same addresses, so an open socket proves nothing — every 10 seconds it checks that its schema and search index still exist.
- The PVC still matters without durable data: it caches the ~2.3 GB of container images, which is the expensive part of a cold start.
Where that logic lives is a choice. Here the API owns it: it notices Floci went away, rebuilds the connection, and rebuilds the state on the way through — which keeps a single code path for a first boot and for a restart. Your application may prefer it elsewhere. It could just as well live in the Floci container itself, seeding the resources, schema and index once Floci is up, and leave the application to assume they are there.