Local services: Postgres with containers, networks, and volumes
A Docker Container runs an image; a Network gives containers a
stable DNS name to reach each other; a Volume keeps data alive
across container replacements; a RemoteImage pins exactly which
image you run. In alchemy all four are resources in your Stack, so
your local dev dependencies get the same treatment as your cloud
infrastructure: a reviewable diff on every change, one deploy that
converges Docker to the declared state, and one destroy that tears
it all down.
You’ll need a Docker daemon reachable from your CLI and the provider
registered — see Setup and the
Docker overview. The full program below is the checked-in
examples/docker-postgres.
- image
- postgres@sha256:…
- volume
- postgres-data
- reaches
- postgres:5432
Pin the image
Section titled “Pin the image”import * as Alchemy from "alchemy";import * as Docker from "@alchemy.run/docker";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";
export default Alchemy.Stack( "DockerPostgresExample", { providers: Layer.merge(Docker.providers(), Alchemy.RandomProvider()), state: Alchemy.localState(), }, Effect.gen(function* () { const image = yield* Docker.RemoteImage("postgres-image", { name: "postgres", tag: "18-alpine", alwaysPull: false, }); // ... }),);RemoteImage pulls postgres:18-alpine through your Docker CLI,
and alwaysPull: false pins it — once the image is present locally,
subsequent deploys diff to a no-op instead of re-pulling on every
run. The Stack uses Alchemy.localState() because there’s no cloud
account behind these resources, and merges in
Alchemy.RandomProvider() for the generated password below.
A network and a volume
Section titled “A network and a volume”const network = yield* Docker.Network("app-network");const data = yield* Docker.Volume("postgres-data");Neither needs any props — the engine generates unique physical names
from the app, stage, and logical ID. Note that changing a Network
or Volume prop (driver, labels, IPv6) is a replacement: the old
one is deleted and a new one created, so treat a Volume’s props as
fixed once it holds data you care about.
A Redacted password
Section titled “A Redacted password”import * as Config from "effect/Config";import * as Option from "effect/Option";
const configuredPassword = yield* Config.Redacted("POSTGRES_PASSWORD").pipe( Config.option,);const password = yield* Option.match(configuredPassword, { onSome: Effect.succeed, onNone: () => Alchemy.makeRandom("PostgresPassword", { bytes: 16 }),});If POSTGRES_PASSWORD is set in the environment it’s used as-is;
otherwise Alchemy.makeRandom generates a stable random secret
(this is what RandomProvider in the Stack config is for). Either
way the value is Redacted, and Container passes environment
values to the daemon via the process environment — only the keys
appear on the docker argv, so the secret never lands in shell
history or a process listing.
The container
Section titled “The container”const postgres = yield* Docker.Container("postgres", { name: "alchemy-example-postgres", image, environment: { POSTGRES_DB: "app", POSTGRES_USER: "alchemy", POSTGRES_PASSWORD: password, }, ports: [{ external: 15432, internal: 5432 }], volumes: [ { hostPath: data.name, containerPath: "/var/lib/postgresql/data" }, ], networks: [{ name: network.name, aliases: ["postgres"] }], healthcheck: { cmd: ["CMD-SHELL", "pg_isready -U alchemy -d app"], interval: "5 seconds", timeout: "5 seconds", retries: 10, }, start: true,});image accepts the RemoteImage resource directly — no manual ref
plumbing. A hostPath set to a volume’s name mounts the named
volume rather than a host directory, the postgres network alias
gives other containers on app-network a stable DNS name, the
healthcheck intervals take Effect Duration strings like
"5 seconds", and start: true actually runs the container
(the default creates it stopped).
Read the bound ports
Section titled “Read the bound ports”// as Stack outputs:return { container: postgres.name, hostPort: postgres.ports };
// or live, from any Effect with the Docker provider:const runtime = yield* Docker.inspectContainer("alchemy-example-postgres");The container’s ports attribute is a record keyed by container
port and protocol — here "5432/tcp" maps to 15432 — which is
what your app’s connection string needs; Docker.inspectContainer
reads the same normalized attributes fresh from the daemon for any
container by name, bound host ports included.
The following examples show optional runtime controls on separate worker containers. Check your image’s filesystem and user requirements before applying them to the Postgres container above.
Limit memory, CPU, and processes
Section titled “Limit memory, CPU, and processes”const worker = yield* Docker.Container("worker", { image: "alpine:3.22", command: ["sleep", "3600"], memory: "256m", memorySwap: "256m", memoryReservation: "128m", cpus: 0.5, pidsLimit: 128, start: true,});memory is a hard RAM limit; memoryReservation is a soft
limit under contention. memorySwap limits RAM plus swap, not
swap alone. Setting it equal to memory disables swap for this
container where supported. cpus: 0.5 limits CPU time to half
a core, and pidsLimit limits the number of processes and threads.
These limits apply to ordinary Docker Engine containers, not Swarm
services.
Restrict privileges and writable paths
Section titled “Restrict privileges and writable paths”const worker = yield* Docker.Container("restricted-worker", { image: "alpine:3.22", command: ["sleep", "3600"], readOnlyRootFilesystem: true, capDrop: ["ALL"], noNewPrivileges: true, user: "1000:1000", init: true, tmpfs: [{ path: "/tmp", size: "32m", mode: 0o1777 }], start: true,});readOnlyRootFilesystem is Alchemy’s name for Docker’s --read-only:
writeable directories must be mounted separately. The tmpfs
above provides ephemeral scratch space; use a named Volume for
data that must survive replacement. capDrop: ["ALL"] removes
Linux capabilities, noNewPrivileges prevents gaining privileges
through exec, and user sets the runtime UID/GID. init handles
signal forwarding and reaping child processes. Only grant the
capabilities and filesystem access your image actually requires.
Configure host security profiles
Section titled “Configure host security profiles”const worker = yield* Docker.Container("profiled-worker", { image: "alpine:3.22", seccompProfile: "./security/worker-seccomp.json", appArmorProfile: "worker-profile",});seccompProfile points to a JSON file on the machine running
Alchemy’s Docker CLI. Alchemy hashes its contents, so editing
the file recreates the container even when its path stays the
same. appArmorProfile names an AppArmor policy that must already
be loaded on the Docker host; its availability is host-dependent.
Docker’s default confinement applies when you leave these settings
unset. Avoid "unconfined" unless disabling a policy is intentional.
You can also provide groupAdd: ["2000"] for supplementary groups
and ulimits: { nofile: { soft: 1024, hard: 4096 } } for per-process
limits. userNamespace selects a user-namespace mode; userNamespace: "host"
opts out of daemon user remapping rather than strengthening
isolation.
Rotate logs and control shutdown
Section titled “Rotate logs and control shutdown”const worker = yield* Docker.Container("logged-worker", { image: "alpine:3.22", logDriver: "local", logOptions: { "max-size": "10m", "max-file": "3" }, oomScoreAdjustment: 200, stopSignal: "SIGTERM", stopTimeout: "15 seconds",});logDriver and logOptions configure Docker-managed logging;
supported options depend on the selected driver. oomScoreAdjustment
adjusts Linux’s preference for choosing a process under memory
pressure; it does not disable the OOM killer. stopSignal
selects the graceful-stop signal, and stopTimeout sets the
grace period before Docker forces termination.
Changing these create-time settings replaces a managed container.
Replacement is delete-first, so account for downtime and keep
persistent data in volumes. Unset options preserve Docker defaults;
invalid combinations such as memorySwap without memory fail
validation before container creation. See the
Container API reference for all
properties and their types.
Where next
Section titled “Where next”- Build & push an image — build your own image from a Dockerfile and push it to a registry.
- Docker overview — all Docker resources at a glance,
including how adoption of pre-existing containers, networks, and
volumes is gated behind
adopt.
Reference: