Skip to content

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.

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.

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.

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.

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

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

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.

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.

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.

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.

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